Compare commits

..

7 Commits

Author SHA1 Message Date
Philipp Oppermann
c9ceb8731c Minor improvements 2023-09-14 19:17:56 +02:00
Philipp Oppermann
a02b4d7284 Start section on thread switching 2020-02-11 10:32:02 +01:00
Philipp Oppermann
d3f19e1e92 Start implementation section with Thread definition and alloc_stack function 2020-02-11 10:32:02 +01:00
Philipp Oppermann
4d21a7fc33 Restructure headings 2020-02-11 10:32:02 +01:00
Philipp Oppermann
a90e673e87 Write introduction about threads 2020-02-11 10:32:02 +01:00
Philipp Oppermann
c4816f0256 Write introduction about multitasking 2020-02-11 10:32:02 +01:00
Philipp Oppermann
16356ca821 Create skeleton for new post about threads 2020-02-11 10:32:02 +01:00
321 changed files with 1979 additions and 33504 deletions

View File

@@ -1,4 +1,4 @@
name: Blog
name: Build Site
on:
push:
@@ -20,7 +20,9 @@ jobs:
- uses: actions/checkout@v1
- name: 'Download Zola'
run: curl -sL https://github.com/getzola/zola/releases/download/v0.17.2/zola-v0.17.2-x86_64-unknown-linux-gnu.tar.gz | tar zxv
run: curl -sL https://github.com/getzola/zola/releases/download/v0.9.0/zola-v0.9.0-x86_64-unknown-linux-gnu.tar.gz | tar zxv
- name: "Install Python Tools"
run: python -m pip install --upgrade pip setuptools wheel
- name: 'Install Python Libraries'
run: python -m pip install --user -r requirements.txt
working-directory: "blog"
@@ -38,6 +40,21 @@ jobs:
name: generated_site
path: blog/public
zola_check:
name: "Zola Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: 'Download Zola'
run: curl -sL https://github.com/getzola/zola/releases/download/v0.9.0/zola-v0.9.0-x86_64-unknown-linux-gnu.tar.gz | tar zxv
- name: "Run zola check"
run: ../zola check
working-directory: "blog"
continue-on-error: true
check_spelling:
name: "Check Spelling"
runs-on: ubuntu-latest
@@ -45,16 +62,16 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Typo Check
uses: crate-ci/typos@v1.1.9
with:
files: blog
- run: curl -L https://git.io/misspell | bash
name: "Install misspell"
- run: bin/misspell -error blog/content
name: "Check for common typos"
deploy_site:
name: "Deploy Generated Site"
runs-on: ubuntu-latest
needs: [build_site, check_spelling]
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')
if: github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule')
steps:
- name: "Download Generated Site"
@@ -68,7 +85,7 @@ jobs:
ssh-keyscan github.com >> ~/.ssh/known_hosts
ssh-agent -a $SSH_AUTH_SOCK > /dev/null
ssh-add - <<< "$deploy_key"
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> $GITHUB_ENV
echo ::set-env name=SSH_AUTH_SOCK::$SSH_AUTH_SOCK
env:
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
deploy_key: ${{ secrets.DEPLOY_SSH_KEY }}

View File

@@ -1,27 +0,0 @@
name: Check Links
on:
push:
branches:
- "*"
- "!staging.tmp"
tags:
- "*"
pull_request:
schedule:
- cron: "0 0 1/4 * *" # every 4 days
jobs:
zola_check:
name: "Zola Link Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: "Download Zola"
run: curl -sL https://github.com/getzola/zola/releases/download/v0.17.2/zola-v0.17.2-x86_64-unknown-linux-gnu.tar.gz | tar zxv
- name: "Run zola check"
run: ../zola check
working-directory: "blog"

View File

@@ -1,33 +0,0 @@
name: Build code on schedule
on:
schedule:
- cron: '40 1 * * *' # every day at 1:40
jobs:
trigger-build:
name: Trigger Build
strategy:
matrix:
branch: [
post-01,
post-02,
post-03,
post-04,
post-05,
post-06,
post-07,
post-08,
post-09,
post-10,
post-11,
post-12,
]
runs-on: ubuntu-latest
steps:
- name: Invoke workflow
uses: benc-uk/workflow-dispatch@v1.1
with:
workflow: Code
token: ${{ secrets.SCHEDULED_BUILDS_TOKEN }}
ref: ${{ matrix.branch }}

View File

@@ -1,6 +1,6 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
@@ -174,3 +174,28 @@
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -10,7 +10,7 @@ The code for each post lives in a separate git branch. This makes it possible to
**The code for the latest post is available [here][latest-post].**
[latest-post]: https://github.com/phil-opp/blog_os/tree/post-12
[latest-post]: https://github.com/phil-opp/blog_os/tree/post-11
You can find the branch for each post by following the `(source code)` link in the [post list](#posts) below. The branches are named `post-XX` where `XX` is the post number, for example `post-03` for the _VGA Text Mode_ post or `post-07` for the _Hardware Interrupts_ post. For build instructions, see the Readme of the respective branch.
@@ -59,17 +59,10 @@ The goal of this project is to provide step-by-step tutorials in individual blog
- [Allocator Designs](https://os.phil-opp.com/allocator-designs/)
([source code](https://github.com/phil-opp/blog_os/tree/post-11))
**Multitasking**:
- [Async/Await](https://os.phil-opp.com/async-await/)
([source code](https://github.com/phil-opp/blog_os/tree/post-12))
## First Edition Posts
The current version of the blog is already the second edition. The first edition is outdated and no longer maintained, but might still be useful. The posts of the first edition are:
<details><summary><i>Click to expand</i></summary>
**Bare Bones:**
- [A Minimal x86 Kernel](https://os.phil-opp.com/multiboot-kernel.html)
@@ -112,15 +105,13 @@ The current version of the blog is already the second edition. The first edition
- [Returning from Exceptions](https://os.phil-opp.com/returning-from-exceptions.html)
([source code](https://github.com/phil-opp/blog_os/tree/returning_from_exceptions))
</details>
## License
This project, with exception of the `blog/content` folder, is licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
https://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT)
http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.

1
blog/.gitignore vendored
View File

@@ -1,2 +1 @@
/public
zola

View File

@@ -2,13 +2,12 @@
# -*- coding: utf-8 -*-
import io
import urllib
import datetime
from github import Github
from datetime import datetime, timedelta
g = Github()
one_month_ago = datetime.datetime.now() - datetime.timedelta(days=32)
one_month_ago = datetime.now() - timedelta(days=32)
def filter_date(issue):
return issue.closed_at > one_month_ago
@@ -22,20 +21,20 @@ def format_number(number):
with io.open("templates/auto/recent-updates.html", 'w', encoding='utf8') as recent_updates:
recent_updates.truncate()
relnotes_issues = g.search_issues("is:merged", repo="phil-opp/blog_os", type="pr", label="relnotes")[:100]
recent_relnotes_issues = list(filter(filter_date, relnotes_issues))
relnotes_issues = g.search_issues("is:merged", repo="phil-opp/blog_os", type="pr", label="relnotes")[:10]
recent_relnotes_issues = filter(filter_date, relnotes_issues)
if len(recent_relnotes_issues) == 0:
recent_updates.write(u"No notable updates recently.")
else:
recent_updates.write(u"<ul>\n")
for pr in sorted(recent_relnotes_issues, key=lambda issue: issue.closed_at, reverse=True):
for pr in recent_relnotes_issues:
link = '<a href="' + pr.html_url + '">' + pr.title + "</a> "
iso_date = pr.closed_at.isoformat()
readable_date = pr.closed_at.strftime("%b&nbsp;%d")
datetime_str = '<time datetime="' + iso_date + '">' + readable_date + '</time>'
recent_updates.write(u" <li>" + link + datetime_str + "</li>\n")
datetime = '<time datetime="' + iso_date + '">' + readable_date + '</time>'
recent_updates.write(u" <li>" + link + datetime + "</li>\n")
recent_updates.write(u"</ul>")
@@ -48,42 +47,3 @@ with io.open("templates/auto/stars.html", 'w', encoding='utf8') as stars:
with io.open("templates/auto/forks.html", 'w', encoding='utf8') as forks:
forks.truncate()
forks.write(format_number(repo.forks_count))
# query "This week in Rust OSDev posts"
lines = []
year = 2020
month = 4
while True:
url = "https://rust-osdev.com/this-month/" + str(year) + "-" + str(month).zfill(2) + "/"
try:
urllib.request.urlopen(url)
except urllib.error.HTTPError as e:
break
month_str = datetime.date(1900, month, 1).strftime('%B')
link = '<a href="' + url + '">This Month in Rust OSDev (' + month_str + " " + str(year) + ")</a> "
lines.append(u" <li><b>" + link + "</b></li>\n")
month = month + 1
if month > 12:
month = 1
year = year + 1
lines.reverse()
with io.open("templates/auto/status-updates.html", 'w', encoding='utf8') as status_updates:
status_updates.truncate()
for line in lines:
status_updates.write(line)
with io.open("templates/auto/status-updates-truncated.html", 'w', encoding='utf8') as status_updates:
status_updates.truncate()
for index, line in enumerate(lines):
if index == 5:
break
status_updates.write(line)

View File

@@ -1,177 +1,13 @@
base_url = "https://os.phil-opp.com"
title = "Writing an OS in Rust"
base_url = "https://os.phil-opp.com"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
generate_feed = true
feed_filename = "rss.xml"
compile_sass = true
minify_html = false
ignored_content = ["*/README.md", "*/LICENSE-CC-BY-NC"]
[markdown]
highlight_code = true
highlight_theme = "visual-studio-dark"
smart_punctuation = true
generate_rss = true
[link_checker]
skip_prefixes = [
"https://crates.io/crates", # see https://github.com/rust-lang/crates.io/issues/788
"https://www.amd.com/system/files/TechDocs/", # seems to have problems with PDFs
"https://developer.apple.com/library/archive/qa/qa1118/_index.html", # results in a 401 (I don't know why)
"https://github.com", # rate limiting often leads to "Error 429 Too Many Requests"
"https://www.linkedin.com/", # seems to send invalid HTTP status codes
]
skip_anchor_prefixes = [
"https://github.com/", # see https://github.com/getzola/zola/issues/805
"https://docs.rs/x86_64/0.1.2/src/", # source code highlight
"https://doc.rust-jp.rs/book-ja/", # seems like Zola has problems with Japanese anchor names
"https://doc.rust-jp.rs/edition-guide/rust-2018", # seems like Zola has problems with Japanese anchor names
"https://doc.rust-jp.rs/rust-nomicon-ja/", # seems like Zola has problems with Japanese anchor names
]
ignored_content = ["*/README.md"]
[extra]
subtitle = "Philipp Oppermann's blog"
author = { name = "Philipp Oppermann" }
default_language = "en"
languages = ["en", "zh-CN", "zh-TW", "fr", "ja", "fa", "ru", "ko"]
[translations]
lang_name = "English (original)"
toc = "Table of Contents"
all_posts = "« All Posts"
comments = "Comments"
comments_notice = "Please leave your comments in English if possible."
readmore = "read&nbsp;more&nbsp;»"
not_translated = "(This post is not translated yet.)"
translated_content = "Translated Content:"
translated_content_notice = "This is a community translation of the <strong><a href=\"_original.permalink_\">_original.title_</a></strong> post. It might be incomplete, outdated or contain errors. Please report any issues!"
translated_by = "Translation by"
translation_contributors = "With contributions from"
word_separator = "and"
# Chinese (simplified)
[languages.zh-CN]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.zh-CN.translations]
lang_name = "Chinese (simplified)"
toc = "目录"
all_posts = "« 所有文章"
comments = "评论"
comments_notice = "请尽可能使用英语评论。"
readmore = "更多 »"
not_translated = "(该文章还没有被翻译。)"
translated_content = "翻译内容:"
translated_content_notice = "这是对原文章 <strong><a href=\"_original.permalink_\">_original.title_</a></strong> 的社区中文翻译。它可能不完整,过时或者包含错误。可以在 <a href=\"https://github.com/phil-opp/blog_os/issues/961\">这个 Issue</a> 上评论和提问!"
translated_by = "翻译者:"
translation_contributors = "With contributions from"
word_separator = "和"
# Chinese (traditional)
[languages.zh-TW]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.zh-TW.translations]
lang_name = "Chinese (traditional)"
toc = "目錄"
all_posts = "« 所有文章"
comments = "評論"
comments_notice = "請儘可能使用英語評論。"
readmore = "更多 »"
not_translated = "(該文章還沒有被翻譯。)"
translated_content = "翻譯內容:"
translated_content_notice = "這是對原文章 <strong><a href=\"_original.permalink_\">_original.title_</a></strong> 的社區中文翻譯。它可能不完整,過時或者包含錯誤。可以在 <a href=\"https://github.com/phil-opp/blog_os/issues/961\">這個 Issue</a> 上評論和提問!"
translated_by = "翻譯者:"
translation_contributors = "With contributions from"
word_separator = "和"
# Japanese
[languages.ja]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.ja.translations]
lang_name = "Japanese"
toc = "目次"
all_posts = "« すべての記事へ"
comments = "コメント"
comments_notice = "可能な限りコメントは英語で残すようにしてください。"
readmore = "もっと読む »"
not_translated = "(この記事はまだ翻訳されていません。)"
translated_content = "この記事は翻訳されたものです:"
translated_content_notice = "この記事は<strong><a href=\"_original.permalink_\">_original.title_</a></strong>をコミュニティの手により翻訳したものです。そのため、翻訳が完全・最新でなかったり、原文にない誤りを含んでいる可能性があります。問題があれば<a href=\"https://github.com/phil-opp/blog_os/issues/906\">このissue</a>上で報告してください!"
translated_by = "翻訳者:"
translation_contributors = "With contributions from"
word_separator = "及び"
# Persian
[languages.fa]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.fa.translations]
lang_name = "Persian"
toc = "فهرست مطالب"
all_posts = "« همه پست‌ها"
comments = "نظرات"
comments_notice = "لطفا نظرات خود را در صورت امکان به انگلیسی بنویسید."
readmore = "ادامه‌مطلب»"
not_translated = "(.این پست هنوز ترجمه نشده است)"
translated_content = "محتوای ترجمه شده:"
translated_content_notice = "این یک ترجمه از جامعه کاربران برای پست <strong><a href=\"_original.permalink_\">_original.title_</a></strong> است. ممکن است ناقص، منسوخ شده یا دارای خطا باشد. لطفا هر گونه مشکل را در <a href=\"https://github.com/phil-opp/blog_os/issues/908\">این ایشو</a> گزارش دهید!"
translated_by = "ترجمه توسط"
translation_contributors = "With contributions from"
word_separator = "و"
# Russian
[languages.ru]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.ru.translations]
lang_name = "Russian"
toc = "Содержание"
all_posts = Все посты"
comments = "Комментарии"
comments_notice = "Пожалуйста, оставляйте комментарии на английском по возможности."
readmore = "читать&nbsp;дальше&nbsp;»"
not_translated = "(Этот пост еще не переведен.)"
translated_content = "Переведенное содержание:"
translated_content_notice = "Это перевод сообщества поста <strong><a href=\"_original.permalink_\">_original.title_</a></strong>. Он может быть неполным, устаревшим или содержать ошибки. Пожалуйста, сообщайте о любых проблемах!"
translated_by = "Перевод сделан"
translation_contributors = "With contributions from"
word_separator = "и"
# French
[languages.fr]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.fr.translations]
lang_name = "French"
toc = "Table des matières"
all_posts = "« Tous les articles"
comments = "Commentaires"
comments_notice = "Veuillez commenter en Anglais si possible."
readmore = "Voir&nbsp;plus&nbsp;»"
not_translated = "(Cet article n'est pas encore traduit.)"
translated_content = "Contenu traduit : "
translated_content_notice = "Ceci est une traduction communautaire de l'article <strong><a href=\"_original.permalink_\">_original.title_</a></strong>. Il peut être incomplet, obsolète ou contenir des erreurs. Veuillez signaler les quelconques problèmes !"
translated_by = "Traduit par : "
translation_contributors = "With contributions from"
word_separator = "et"
# Korean
[languages.ko]
title = "Writing an OS in Rust"
description = "This blog series creates a small operating system in the Rust programming language. Each post is a small tutorial and includes all needed code."
[languages.ko.translations]
lang_name = "Korean"
toc = "목차"
all_posts = "« 모든 게시글"
comments = "댓글"
comments_notice = "댓글은 가능하면 영어로 작성해주세요."
readmore = "더&nbsp;읽기&nbsp;»"
not_translated = "(아직 번역이 완료되지 않은 게시글입니다)"
translated_content = "번역된 내용 : "
translated_content_notice = "이것은 커뮤니티 멤버가 <strong><a href=\"_original.permalink_\">_original.title_</a></strong> 포스트를 번역한 글입니다. 부족한 설명이나 오류, 혹은 시간이 지나 더 이상 유효하지 않은 정보를 발견하시면 제보해주세요!"
translated_by = "번역한 사람 : "
translation_contributors = "With contributions from"
word_separator = "와"

View File

@@ -4,13 +4,13 @@ This folder contains the content for the _"Writing an OS in Rust"_ blog.
## License
This folder is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License, available in [LICENSE-CC-BY-NC](LICENSE-CC-BY-NC) or under <https://creativecommons.org/licenses/by-nc/4.0/>.
This folder is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License, available in [LICENSE-CC-BY-NC](LICENSE-CC-BY-NC) or under <http://creativecommons.org/licenses/by-nc/4.0/>.
All _code examples_ between markdown code blocks denoted by three backticks (<code>\`\`\`</code>) are additionally licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](../../LICENSE-APACHE) or
https://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](../../LICENSE-MIT) or https://opensource.org/licenses/MIT)
http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](../../LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">نوشتن یک سیستم عامل با راست</h1>
<div class="front-page-introduction right-to-left">
این مجموعه بلاگ یک سیستم عامل کوچک در [زبان برنامه نویسی Rust](https://www.rust-lang.org/) ایجاد می کند. هر پست یک آموزش کوچک است و شامل تمام کدهای مورد نیاز است ، بنابراین اگر دوست دارید می توانید آن را دنبال کنید. کد منبع نیز در [مخزن گیت‌هاب](https://github.com/phil-opp/blog_os) مربوطه موجود است.
اخرین پست: <!-- latest-post -->
</div>

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">Écrire un OS en Rust</h1>
<div class="front-page-introduction">
L'objectif de ce blog est de créer un petit système d'exploitation avec le [langage de programmation Rust](https://www.rust-lang.org/). Chaque article est un petit tutoriel et comprend tout le code nécessaire, vous pouvez donc essayer en même temps si vous le souhaitez. Le code source est aussi disponible dans le [dépôt GitHub](https://github.com/phil-opp/blog_os) correspondant.
Dernier article : <!-- latest-post -->
</div>

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">RustでOSを書く</h1>
<div class="front-page-introduction">
このブログシリーズでは、ちょっとしたオペレーティングシステムを[Rustプログラミング言語](https://www.rust-lang.org/)を使って作ります。それぞれの記事が小さなチュートリアルになっており、必要なコードも全て記事内に記されているので、一つずつ読み進めて行けば理解できるでしょう。対応した[Githubリポジトリ](https://github.com/phil-opp/blog_os)でソースコードを見ることもできます。
最新記事: <!-- latest-post -->
</div>

View File

@@ -1,14 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">Rust로 OS 구현하기</h1>
<div class="front-page-introduction">
이 블로그 시리즈는 [Rust 프로그래밍 언어](https://www.rust-lang.org/)로 작은 OS를 구현하는 것을 주제로 합니다.
각 포스트는 구현에 필요한 소스 코드를 포함한 작은 튜토리얼 형식으로 구성되어 있습니다. 소스 코드는 이 블로그의 [Github 저장소](https://github.com/phil-opp/blog_os)에서도 확인하실 수 있습니다.
최신 포스트: <!-- latest-post -->
</div>

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">Writing an OS in Rust</h1>
<div class="front-page-introduction">
This blog series creates a small operating system in the [Rust programming language](https://www.rust-lang.org/). Each post is a small tutorial and includes all needed code, so you can follow along if you like. The source code is also available in the corresponding [Github repository](https://github.com/phil-opp/blog_os).
Latest post: <!-- latest-post -->
</div>

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">Собственная операционная система на Rust</h1>
<div class="front-page-introduction">
Этот блог посвящен написанию маленькой операционной системы на [языке программирования Rust](https://www.rust-lang.org/). Каждый пост &mdash; это маленькое руководство, включающее в себя весь необходимый код, &mdash; вы сможете следовать ему, если пожелаете. Исходный код также доступен в соотвестующем [репозитории на Github](https://github.com/phil-opp/blog_os).
Последний пост: <!-- latest-post -->
</div>

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">用Rust写一个操作系统</h1>
<div class="front-page-introduction">
这个博客系列用[Rust编程语言](https://www.rust-lang.org/)编写了一个小操作系统。每篇文章都是一个小教程,并且包含了所有代码,你可以跟着一起学习。源代码也放在了[Github 仓库](https://github.com/phil-opp/blog_os)。
最新文章: <!-- latest-post -->
</div>

View File

@@ -1,13 +0,0 @@
+++
template = "edition-2/index.html"
+++
<h1 style="visibility: hidden; height: 0px; margin: 0px; padding: 0px;">Writing an OS in Rust</h1>
<div class="front-page-introduction">
This blog series creates a small operating system in the [Rust programming language](https://www.rust-lang.org/). Each post is a small tutorial and includes all needed code, so you can follow along if you like. The source code is also available in the corresponding [Github repository](https://github.com/phil-opp/blog_os).
Latest post: <!-- latest-post -->
</div>

View File

@@ -1,5 +0,0 @@
+++
title = "First Edition"
template = "edition-1/index.html"
aliases = ["first-edition/index.html"]
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Handling Exceptions using naked Functions"
sort_by = "weight"
template = "edition-1/handling-exceptions-with-naked-fns.html"
insert_anchor_links = "left"
aliases = ["first-edition/extra/naked-exceptions/index.html"]
+++

View File

@@ -1,527 +0,0 @@
+++
title = " یک باینری مستقل Rust"
weight = 1
path = "fa/freestanding-rust-binary"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "80136cc0474ae8d2da04f391b5281cfcda068c1a"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
اولین قدم برای نوشتن سیستم‌عامل، ساخت یک باینری راست (کلمه: Rust) هست که به کتابخانه استاندارد نیازمند نباشد. این باعث می‌شود تا بتوانیم کد راست را بدون سیستم‌عامل زیرین، بر روی سخت افزار [bare metal] اجرا کنیم.
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
این بلاگ بصورت آزاد بر روی [گیت‌هاب] توسعه داده شده. اگر مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. همچنین می‌توانید [در زیر] این پست کامنت بگذارید. سورس کد کامل این پست را می‌توانید در بِرَنچ [`post-01`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## مقدمه
برای نوشتن هسته سیستم‌عامل، ما به کدی نیاز داریم که به هیچ یک از ویژگی‌های سیستم‌عامل نیازی نداشته باشد. یعنی نمی‌توانیم از نخ‌ها (ترجمه: Threads)، فایل‌ها، حافظه هیپ (کلمه: Heap)، شبکه، اعداد تصادفی، ورودی استاندارد، یا هر ویژگی دیگری که نیاز به انتزاعات سیستم‌عامل یا سخت‌افزار خاصی داشته، استفاده کنیم. منطقی هم به نظر می‌رسد، چون ما سعی داریم سیستم‌عامل و درایور‌های خودمان را بنویسیم.
نداشتن انتزاعات سیستم‌عامل به این معنی هست که نمی‌توانیم از بخش زیادی از [کتابخانه استاندارد راست] استفاده کنیم، اما هنوز بسیاری از ویژگی‌های راست هستند که می‌توانیم از آن‌ها استفاده کنیم. به عنوان مثال، می‌توانیم از [iterator] ها، [closure] ها، [pattern matching]، [option]، [result]، [string formatting] و البته [سیستم ownership] استفاده کنیم. این ویژگی‌ها به ما امکان نوشتن هسته به طور رسا، سطح بالا و بدون نگرانی درباره [رفتار تعریف نشده] و [امنیت حافظه] را میدهند.
[option]: https://doc.rust-lang.org/core/option/
[result]:https://doc.rust-lang.org/core/result/
[کتابخانه استاندارد راست]: https://doc.rust-lang.org/std/
[iterators]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[string formatting]: https://doc.rust-lang.org/core/macro.write.html
[سیستم ownership]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[رفتار تعریف نشده]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[امنیت حافظه]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
برای ساختن یک هسته سیستم‌عامل به زبان راست، باید فایل اجرایی‌ای بسازیم که بتواند بدون سیستم‌عامل زیرین اجرا بشود. چنین فایل اجرایی، فایل اجرایی مستقل (ترجمه: freestanding) یا فایل اجرایی “bare-metal” نامیده می‌شود.
این پست قدم‌های لازم برای ساخت یک باینری مستقل راست و اینکه چرا این قدم‌ها نیاز هستند را توضیح می‌دهد. اگر علاقه‌ایی به خواندن کل توضیحات ندارید، می‌توانید **[به قسمت خلاصه مراجعه کنید](#summary)**.
## غیر فعال کردن کتابخانه استاندارد
به طور پیش‌فرض تمام کِرِیت‌های راست، از [کتابخانه استاندارد] استفاده می‌کنند(لینک به آن دارند)، که به سیستم‌عامل برای قابلیت‌هایی مثل نخ‌ها، فایل‌ها یا شبکه وابستگی دارد. همچنین به کتابخانه استاندارد زبان سی، `libc` هم وابسطه هست که با سرویس‌های سیستم‌عامل تعامل نزدیکی دارند. از آن‌جا که قصد داریم یک سیستم‌عامل بنویسیم، نمی‌توانیم از هیچ کتابخانه‌ایی که به سیستم‌عامل نیاز داشته باشد استفاده کنیم. بنابراین باید اضافه شدن خودکار کتابخانه استاندارد را از طریق [خاصیت `no_std`] غیر فعال کنیم.
[کتابخانه استاندارد]: https://doc.rust-lang.org/std/
[خاصیت `no_std`]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
با ساخت یک اپلیکیشن جدید کارگو شروع می‌کنیم. ساده‌ترین راه برای انجام این کار از طریق خط فرمان است:
```
cargo new blog_os --bin --edition 2018
```
نام پروژه را `blog_os` گذاشتم، اما شما می‌توانید نام دلخواه خود را انتخاب کنید. پرچمِ (ترجمه: Flag) `bin--` مشخص می‌کند که ما می‌خواهیم یک فایل اجرایی ایجاد کنیم (به جای یک کتابخانه) و پرچمِ `edition 2018--` مشخص می‌کند که می‌خواهیم از [ویرایش 2018] زبان راست برای کریت خود استفاده کنیم. وقتی دستور را اجرا می‌کنیم، کارگو ساختار پوشه‌های زیر را برای ما ایجاد می‌کند:
[ویرایش 2018]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
فایل `Cargo.toml` شامل تنظیمات کریت می‌باشد، به عنوان مثال نام کریت، نام نویسنده، شماره [نسخه سمنتیک] و وابستگی‌ها. فایل `src/main.rs` شامل ماژول ریشه برای کریت ما و تابع `main` است. می‌توانید کریت خود را با دستور `cargo build` کامپایل کنید و سپس باینری کامپایل شده `blog_os` را در زیرپوشه `target/debug` اجرا کنید.
[نسخه سمنتیک]: https://semver.org/
### خاصیت `no_std`
در حال حاظر کریت ما بطور ضمنی به کتابخانه استاندارد لینک دارد. بیایید تا سعی کنیم آن را با اضافه کردن [خاصیت `no_std`] غیر فعال کنیم:
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
حالا وقتی سعی می‌کنیم تا بیلد کنیم (با اجرای دستور `cargo build`)، خطای زیر رخ می‌دهد:
```
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
دلیل این خطا این هست که [ماکروی `println`]\(ترجمه: macro) جزوی از کتابخانه استاندارد است، که ما دیگر آن را نداریم. بنابراین نمی‌توانیم چیزی را چاپ کنیم. منطقی هست زیرا `println` در [خروجی استاندارد] می‌نویسد، که یک توصیف کننده فایل (ترجمه: File Descriptor) خاص است که توسط سیستم‌عامل ارائه می‌شود.
[ماکروی `println`]: https://doc.rust-lang.org/std/macro.println.html
[خروجی استاندارد]: https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29
پس بیایید قسمت مروبط به چاپ را پاک کرده و این‌ بار با یک تابع main خالی امتحان کنیم:
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
حالا کامپایلر با کمبود یک تابع `#[panic_handler]` و یک _language item_ روبرو است.
## پیاده‌سازی پنیک (کلمه: Panic)
خاصیت `panic_handler` تابعی را تعریف می‌کند که کامپایلر باید در هنگام رخ دادن یک [پنیک] اجرا کند. کتابخانه استاندارد تابع مدیریت پنیک خود را ارائه می‌دهد، اما در یک محیط `no_std` ما باید خودمان آن را تعریف کنیم.
[پنیک]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// in main.rs
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
[پارامتر `PanicInfo`][PanicInfo] شامل فایل و شماره خطی که پنیک رخ داده و پیام پنیکِ اختیاری می‌باشد. تابع هیچ وقت نباید چیزی را برگرداند به همین دلیل به عنوان یک [تابع واگرا]\(ترجمه: diverging function) بوسیله نوع برگشتی `!` [نوع ”هرگز“] علامت‌گذاری شده است. فعلا کار زیادی نیست که بتوانیم در این تابع انجام دهیم، بنابراین فقط یک حلقه بی‌نهایت می‌نویسیم.
[PanicInfo]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[تابع واگرا]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
[نوع ”هرگز“]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## آیتم زبان `eh_personality`
آیتم‌های زبان، توابع و انواع خاصی هستند که برای استفاده درون کامپایلر ضروری‌اند. به عنوان مثال، تِرِیت [`Copy`]\(کلمه: Trait) یک آیتم زبان است که به کامپایلر می‌گوید کدام انواع دارای [_مفهوم کپی_][`Copy`] هستند. وقتی به [پیاده‌سازی][copy code] آن نگاه می‌کنیم، می‌بینیم که یک خاصیت ویژه `#[lang = "copy"]` دارد که آن را به عنوان یک آیتم زبان تعریف می‌کند.
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[copy code]: https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299
درحالی که می‌توان پیاده‌سازی خاص برای آیتم‌های زبان فراهم کرد، فقط باید به عنوان آخرین راه حل از آن استفاده کرد. زیرا آیتم‌های زبان بسیار در جزئیات پیاده‌سازی ناپایدار هستند و حتی انواع آن‌ها نیز چک نمی‌شود (بنابراین کامپایلر حتی چک نمی‌کند که آرگومان تابع نوع درست را دارد). خوشبختانه یک راه پایدارتر برای حل مشکل آیتم زبان بالا وجود دارد.
[آیتم زبان `eh_personality`] یک تابع را به عنوان تابعی که برای پیاده‌سازی [بازکردن پشته (Stack Unwinding)] استفاده شده، علامت‌گذاری می‌کند. راست بطور پیش‌فرض از _بازکردن_ (ترجمه: unwinding) برای اجرای نابودگرهای (ترجمه: Destructors) تمام متغیرهای زنده درون استک در مواقع [پنیک] استفاده می‌کند. این تضمین می‌کند که تمام حافظه استفاده شده آزاد می‌شود و به نخ اصلی اجازه می‌دهد پنیک را دریافت کرده و اجرا را ادامه دهد. باز کردن، یک فرآیند پیچیده است و به برخی از کتابخانه‌های خاص سیستم‌عامل (به عنوان مثال [libunwind] در لینوکس یا [مدیریت اکسپشن ساخت یافته] در ویندوز) نیاز دارد، بنابراین ما نمی‌خواهیم از آن برای سیستم‌عامل خود استفاده کنیم.
[آیتم زبان `eh_personality`]: https://github.com/rust-lang/rust/blob/edb368491551a77d77a48446d4ee88b35490c565/src/libpanic_unwind/gcc.rs#L11-L45
[بازکردن پشته (Stack Unwinding)]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
[libunwind]: https://www.nongnu.org/libunwind/
[مدیریت اکسپشن ساخت یافته]: https://docs.microsoft.com/en-us/windows/win32/debug/structured-exception-handling
### غیرفعال کردن Unwinding
موارد استفاده دیگری نیز وجود دارد که باز کردن نامطلوب است، بنابراین راست به جای آن گزینه [قطع در پنیک] را فراهم می‌کند. این امر تولید اطلاعات نمادها (ترجمه: Symbol) را از بین می‌برد و بنابراین اندازه باینری را بطور قابل توجهی کاهش می‌دهد. چندین مکان وجود دارد که می توانیم باز کردن را غیرفعال کنیم. ساده‌ترین راه این است که خطوط زیر را به `Cargo.toml` اضافه کنید:
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
این استراتژی پنیک را برای دو پروفایل `dev` (در `cargo build` استفاده می‌شود) و پروفایل `release` (در ` cargo build --release` استفاده می‌شود) تنظیم می‌کند. اکنون آیتم زبان `eh_personality` نباید دیگر لازم باشد.
[قطع در پنیک]: https://github.com/rust-lang/rust/pull/32900
اکنون هر دو خطای فوق را برطرف کردیم. با این حال‌، اگر اکنون بخواهیم آن را کامپایل کنیم، خطای دیگری رخ می‌دهد:
```
> cargo build
error: requires `start` lang_item
```
برنامه ما آیتم زبان `start` که نقطه ورود را مشخص می‌کند، را ندارد.
## خاصیت `start`
ممکن است تصور شود که تابع `main` اولین تابعی است که هنگام اجرای یک برنامه فراخوانی می‌شود. با این حال، بیشتر زبان‌ها دارای [سیستم رانتایم] هستند که مسئول مواردی مانند جمع آوری زباله (به عنوان مثال در جاوا) یا نخ‌های نرم‌افزار (به عنوان مثال goroutines در Go) است. این رانتایم باید قبل از `main` فراخوانی شود، زیرا باید خود را مقداردهی اولیه و آماده کند.
[سیستم رانتایم]: https://en.wikipedia.org/wiki/Runtime_system
در یک باینری معمولی راست که از کتابخانه استاندارد استفاده می‌کند، اجرا در یک کتابخانه رانتایم C به نام `crt0` ("زمان اجرا صفر C") شروع می‌شود، که محیط را برای یک برنامه C تنظیم می‌کند. این شامل ایجاد یک پشته و قرار دادن آرگومان‌ها در رجیسترهای مناسب است. سپس رانتایم C [ورودی رانتایم راست][rt::lang_start] را فراخوانی می‌کند، که با آیتم زبان `start` مشخص شده است. راست فقط یک رانتایم بسیار کوچک دارد، که مواظب برخی از کارهای کوچک مانند راه‌اندازی محافظ‌های سرریز پشته یا چاپ backtrace با پنیک می‌باشد. رانتایم در نهایت تابع `main` را فراخوانی می‌کند.
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
برنامه اجرایی مستقل ما به رانتایم و `crt0` دسترسی ندارد، بنابراین باید نقطه ورود را مشخص کنیم. پیاده‌سازی آیتم زبان `start` کمکی نخواهد کرد، زیرا همچنان به `crt0` نیاز دارد. در عوض، باید نقطه ورود `crt0` را مستقیماً بازنویسی کنیم.
### بازنویسی نقطه ورود
برای اینکه به کامپایلر راست بگوییم که نمی‌خواهیم از زنجیره نقطه ورودی عادی استفاده کنیم، ویژگی `#![no_main]` را اضافه می‌کنیم.
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
ممکن است متوجه شده باشید که ما تابع `main` را حذف کردیم. دلیل این امر این است که `main` بدون یک رانتایم اساسی که آن را صدا کند معنی ندارد. در عوض، ما در حال بازنویسی نقطه ورود سیستم‌عامل با تابع `start_` خود هستیم:
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
با استفاده از ویژگی `[no_mangle]#` ما [name mangling] را غیرفعال می کنیم تا اطمینان حاصل کنیم که کامپایلر راست تابعی با نام `start_` را خروجی می‌دهد. بدون این ویژگی، کامپایلر برخی از نمادهای رمزنگاری شده `ZN3blog_os4_start7hb173fedf945531caE_` را تولید می‌کند تا به هر تابع یک نام منحصر به فرد بدهد. این ویژگی لازم است زیرا در مرحله بعدی باید نام تایع نقطه ورود را به لینکر (کلمه: linker) بگوییم.
ما همچنین باید تابع را به عنوان `"extern "C` علامت‌گذاری کنیم تا به کامپایلر بگوییم که باید از [قرارداد فراخوانی C] برای این تابع استفاده کند (به جای قرارداد مشخص نشده فراخوانی راست). دلیل نامگذاری تابع `start_` این است که این نام نقطه پیش‌فرض ورودی برای اکثر سیستم‌ها است.
[name mangling]: https://en.wikipedia.org/wiki/Name_mangling
[قرارداد فراخوانی C]: https://en.wikipedia.org/wiki/Calling_convention
نوع بازگشت `!` به این معنی است که تایع واگرا است، یعنی اجازه بازگشت ندارد. این مورد لازم است زیرا نقطه ورود توسط هیچ تابعی فراخوانی نمی‌شود، بلکه مستقیماً توسط سیستم‌عامل یا بوت‌لودر فراخوانی می‌شود. بنابراین به جای بازگشت، نقطه ورود باید به عنوان مثال [فراخوان سیستمی `exit`] از سیستم‌عامل را فراخوانی کند. در مورد ما، خاموش کردن دستگاه می‌تواند اقدامی منطقی باشد، زیرا در صورت بازگشت باینری مستقل دیگر کاری برای انجام دادن وجود ندارد. در حال حاضر، ما این نیاز را با حلقه‌های بی‌پایان انجام می‌دهیم.
[فراخوان سیستمی `exit`]: https://en.wikipedia.org/wiki/Exit_(system_call)
حالا وقتی `cargo build` را اجرا می‌کنیم، با یک خطای _لینکر_ زشت مواجه می‌شویم.
## خطا‌های لینکر (Linker)
لینکر برنامه‌ای است که کد تولید شده را ترکیب کرده و یک فایل اجرایی می‌سازد. از آن‌جا که فرمت اجرایی بین لینوکس، ویندوز و macOS متفاوت است، هر سیستم لینکر خود را دارد که خطای متفاوتی ایجاد می‌کند. علت اصلی خطاها یکسان است: پیکربندی پیش‌فرض لینکر فرض می‌کند که برنامه ما به رانتایم C وابسته است، که این طور نیست.
برای حل خطاها، باید به لینکر بگوییم که نباید رانتایم C را اضافه کند. ما می‌توانیم این کار را با اضافه کردن مجموعه‌ای از آرگمان‌ها به لینکر یا با ساختن یک هدف (ترجمه: Target) bare metal انجام دهیم.
### بیلد کردن برای یک هدف bare metal
راست به طور پیش‌فرض سعی در ایجاد یک اجرایی دارد که بتواند در محیط سیستم فعلی شما اجرا شود. به عنوان مثال، اگر از ویندوز در `x86_64` استفاده می‌کنید، راست سعی در ایجاد یک `exe.` اجرایی ویندوز دارد که از دستورالعمل‌های `x86_64` استفاده می‌کند. به این محیط سیستم "میزبان" شما گفته می‌شود.
راست برای توصیف محیط‌های مختلف، از رشته‌ای به نام [_target triple_]\(سه‌گانه هدف) استفاده می‌کند. با اجرای `rustc --version --verbose` می‌توانید target triple را برای سیستم میزبان خود مشاهده کنید:
[_target triple_]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
خروجی فوق از یک سیستم لینوکس `x86_64` است. می‌بینیم که سه‌گانه میزبان `x86_64-unknown-linux-gnu` است که شامل معماری پردازنده (`x86_64`)، فروشنده (`ناشناخته`)، سیستم‌عامل (` linux`) و [ABI] (`gnu`) است.
[ABI]: https://en.wikipedia.org/wiki/Application_binary_interface
با کامپایل کردن برای سه‌گانه میزبان‌مان، کامپایلر راست و لینکر فرض می‌کنند که یک سیستم‌عامل زیرین مانند Linux یا Windows وجود دارد که به طور پیش‌فرض از رانتایم C استفاده می‌کند، که باعث خطاهای لینکر می‌شود. بنابراین برای جلوگیری از خطاهای لینکر، می‌توانیم برای محیطی متفاوت و بدون سیستم‌عامل زیرین کامپایل کنیم.
یک مثال برای چنین محیطِ bare metal ی، سه‌گانه هدف `thumbv7em-none-eabihf` است، که یک سیستم [تعبیه شده][ARM] را توصیف می‌کند. جزئیات مهم نیستند، مهم این است که سه‌گانه هدف فاقد سیستم‌عامل زیرین باشد، که با `none` در سه‌گانه هدف نشان داده می‌شود. برای این که بتوانیم برای این هدف کامپایل کنیم، باید آن را به rustup اضافه کنیم:
[تعبیه شده]: https://en.wikipedia.org/wiki/Embedded_system
[ARM]: https://en.wikipedia.org/wiki/ARM_architecture
```
rustup target add thumbv7em-none-eabihf
```
با این کار نسخه‌ای از کتابخانه استاندارد (و core) برای سیستم بارگیری می‌شود. اکنون می‌توانیم برای این هدف اجرایی مستقل خود را بسازیم:
```
cargo build --target thumbv7em-none-eabihf
```
با استفاده از یک آرگومان `target--`، ما اجرایی خود را برای یک سیستم هدف bare metal [کراس کامپایل] می‌کنیم. از آن‌جا که سیستم هدف فاقد سیستم‌عامل است، لینکر سعی نمی‌کند رانتایم C را به آن پیوند دهد و بیلد ما بدون هیچ گونه خطای لینکر با موفقیت انجام می‌شود.
[کراس کامپایل]: https://en.wikipedia.org/wiki/Cross_compiler
این روشی است که ما برای ساخت هسته سیستم‌عامل خود استفاده خواهیم کرد. به جای `thumbv7em-none-eabihf`، ما از یک [هدف سفارشی] استفاده خواهیم کرد که یک محیط `x86_64` bare metal را توصیف می‌کند. جزئیات در پست بعدی توضیح داده خواهد شد.
[هدف سفارشی]: https://doc.rust-lang.org/rustc/targets/custom.html
### آرگومان‌های لینکر
به جای کامپایل کردن برای یک سیستم bare metal، می‌توان خطاهای لینکر را با استفاده از مجموعه خاصی از آرگومان‌ها به لینکر حل کرد. این روشی نیست که ما برای هسته خود استفاده کنیم، بنابراین این بخش اختیاری است و فقط برای کامل بودن ارائه می‌شود. برای نشان دادن محتوای اختیاری، روی _"آرگومان‌های لینکر"_ در زیر کلیک کنید.
<details>
<summary>آرگومان‌های لینکر</summary>
در این بخش، ما در مورد خطاهای لینکر که در لینوکس، ویندوز و macOS رخ می‌دهد بحث می‌کنیم و نحوه حل آن‌ها را با استفاده از آرگومان‌های اضافی به لینکر توضیح می‌دهیم. توجه داشته باشید که فرمت اجرایی و لینکر بین سیستم‌عامل‌ها متفاوت است، بنابراین برای هر سیستم مجموعه‌ای متفاوت از آرگومان‌ها مورد نیاز است.
#### لینوکس
در لینوکس خطای لینکر زیر رخ می‌دهد (کوتاه شده):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
مشکل این است که لینکر به طور پیش‌فرض شامل روال راه‌اندازی رانتایم C است که به آن `start_` نیز گفته می‌شود. به برخی از نمادهای کتابخانه استاندارد C یعنی `libc` نیاز دارد که به دلیل ویژگی`no_std` آن‌ها را نداریم، بنابراین لینکر نمی‌تواند این مراجع را پیدا کند. برای حل این مسئله، با استفاده از پرچم `nostartfiles-` می‌توانیم به لینکر بگوییم که نباید روال راه‌اندازی C را لینک دهد.
یکی از راه‌های عبور صفات لینکر از طریق cargo، دستور `cargo rustc` است. این دستور دقیقاً مانند `cargo build` رفتار می‌کند، اما اجازه می‌دهد گزینه‌ها را به `rustc`، کامپایلر اصلی راست انتقال دهید. `rustc` دارای پرچم`C link-arg-` است که آرگومان را به لینکر منتقل می‌کند. با ترکیب همه این‌ها، دستور بیلد جدید ما به این شکل است:
```
cargo rustc -- -C link-arg=-nostartfiles
```
اکنون کریت ما بصورت اجرایی مستقل در لینوکس ساخته می‌شود!
لازم نیست که صریحاً نام تابع نقطه ورود را مشخص کنیم، زیرا لینکر به طور پیش‌فرض به دنبال تابعی با نام `start_` می‌گردد.
#### ویندوز
در ویندوز، یک خطای لینکر متفاوت رخ می‌دهد (کوتاه شده):
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
خطای "entry point must be defined" به این معنی است که لینکر نمی‌تواند نقطه ورود را پیدا کند. در ویندوز، نام پیش‌فرض نقطه ورود [بستگی به زیر سیستم استفاده شده دارد] [windows-subsystem]. برای زیر سیستم `CONSOLE` لینکر به دنبال تابعی به نام `mainCRTStartup` و برای زیر سیستم `WINDOWS` به دنبال تابعی به نام `WinMainCRTStartup` می‌گردد. برای بازنویسی این پیش‌فرض و به لینکر گفتن که در عوض به دنبال تابع `_start` ما باشد ، می توانیم یک آرگومان `ENTRY/` را به لینکر ارسال کنیم:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
از متفاوت بودن فرمت آرگومان، به وضوح می‌فهمیم که لینکر ویندوز یک برنامه کاملاً متفاوت از لینکر Linux است.
اکنون یک خطای لینکر متفاوت رخ داده است:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
این خطا به این دلیل رخ می‌دهد که برنامه‌های اجرایی ویندوز می‌توانند از [زیر سیستم های][windows-subsystems] مختلف استفاده کنند. برای برنامه‌های عادی، بسته به نام نقطه ورود استنباط می شوند: اگر نقطه ورود `main` نامگذاری شود، از زیر سیستم `CONSOLE` و اگر نقطه ورود `WinMain` نامگذاری شود، از زیر سیستم `WINDOWS` استفاده می‌شود. از آن‌جا که تابع `start_` ما نام دیگری دارد، باید زیر سیستم را صریحاً مشخص کنیم:
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
ما در اینجا از زیر سیستم `CONSOLE` استفاده می‌کنیم، اما زیر سیستم `WINDOWS` نیز کار خواهد کرد. به جای اینکه چند بار از `C link-arg-` استفاده کنیم، از`C link-args-` استفاده می‌کنیم که لیستی از آرگومان‌ها به صورت جدا شده با فاصله را دریافت می‌کند.
با استفاده از این دستور، اجرایی ما باید با موفقیت بر روی ویندوز ساخته شود.
#### macOS
در macOS، خطای لینکر زیر رخ می‌دهد (کوتاه شده):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
این پیام خطا به ما می‌گوید که لینکر نمی‌تواند یک تابع نقطه ورود را با نام پیش‌فرض `main` پیدا کند (به دلایلی همه توابع در macOS دارای پیشوند `_` هستند). برای تنظیم نقطه ورود به تابع `start_` ، آرگومان لینکر `e-` را استفاده می‌کنیم:
```
cargo rustc -- -C link-args="-e __start"
```
پرچم `e-` نام تابع نقطه ورود را مشخص می‌کند. از آن‌جا که همه توابع در macOS دارای یک پیشوند اضافی `_` هستند، ما باید به جای `start_` نقطه ورود را روی `start__` تنظیم کنیم.
اکنون خطای لینکر زیر رخ می‌دهد:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
سیستم‌عامل مک‌ [رسماً باینری‌هایی را که بطور استاتیک با هم پیوند دارند پشتیبانی نمی‌کند] و بطور پیش‌فرض به برنامه‌هایی برای پیوند دادن کتابخانه `libSystem` نیاز دارد. برای تغییر این حالت و پیوند دادن یک باینری استاتیک، پرچم `static-` را به لینکر ارسال می‌کنیم:
[باینری‌هایی را که بطور استاتیک با هم پیوند دارند پشتیبانی نمی‌کند]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
این نیز کافی نیست، سومین خطای لینکر رخ می‌دهد:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
این خطا رخ می‌دهد زیرا برنامه های موجود در macOS به طور پیش‌فرض به `crt0` ("رانتایم صفر C") پیوند دارند. این همان خطایی است که در لینوکس داشتیم و با افزودن آرگومان لینکر `nostartfiles-` نیز قابل حل است:
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
اکنون برنامه ما باید با موفقیت بر روی macOS ساخته شود.
#### متحد کردن دستورات Build
در حال حاضر بسته به سیستم‌عامل میزبان، دستورات ساخت متفاوتی داریم که ایده آل نیست. برای جلوگیری از این، می‌توانیم فایلی با نام `cargo/config.toml.` ایجاد کنیم که حاوی آرگومان‌های خاص هر پلتفرم است:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
کلید `rustflags` شامل آرگومان‌هایی است که بطور خودکار به هر فراخوانی `rustc` اضافه می‌شوند. برای کسب اطلاعات بیشتر در مورد فایل `cargo/config.toml.` به [اسناد رسمی](https://doc.rust-lang.org/cargo/reference/config.html) مراجعه کنید.
اکنون برنامه ما باید در هر سه سیستم‌عامل با یک `cargo build` ساده قابل بیلد باشد.
#### آیا شما باید این کار را انجام دهید؟
اگرچه ساخت یک اجرایی مستقل برای لینوکس، ویندوز و macOS امکان پذیر است، اما احتمالاً ایده خوبی نیست. چرا که اجرایی ما هنوز انتظار موارد مختلفی را دارد، به عنوان مثال با فراخوانی تابع `start_` یک پشته مقداردهی اولیه شده است. بدون رانتایم C، ممکن است برخی از این الزامات برآورده نشود، که ممکن است باعث شکست برنامه ما شود، به عنوان مثال از طریق `segmentation fault`.
اگر می خواهید یک باینری کوچک ایجاد کنید که بر روی سیستم‌عامل موجود اجرا شود، اضافه کردن `libc` و تنظیم ویژگی `[start]#` همان‌طور که [اینجا](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html) شرح داده شده است، احتمالاً ایده بهتری است.
</details>
## خلاصه {#summary}
یک باینری مستقل مینیمال راست مانند این است:
`src/main.rs`:
```rust
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`:
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic
# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic
```
برای ساخت این باینری، ما باید برای یک هدف bare metal مانند `thumbv7em-none-eabihf` کامپایل کنیم:
```
cargo build --target thumbv7em-none-eabihf
```
یک راه دیگر این است که می‌توانیم آن را برای سیستم میزبان با استفاده از آرگومان‌های اضافی لینکر کامپایل کنیم:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
توجه داشته باشید که این فقط یک نمونه حداقلی از باینری مستقل راست است. این باینری انتظار چیزهای مختلفی را دارد، به عنوان مثال با فراخوانی تابع `start_` یک پشته مقداردهی اولیه می‌شود. **بنابراین برای هر گونه استفاده واقعی از چنین باینری، مراحل بیشتری لازم است**.
## بعدی چیست؟
[پست بعدی] مراحل مورد نیاز برای تبدیل باینری مستقل به حداقل هسته سیستم‌عامل را توضیح می‌دهد. که شامل ایجاد یک هدف سفارشی، ترکیب اجرایی ما با بوت‌لودر و یادگیری نحوه چاپ چیزی در صفحه است.
[پست بعدی]: @/edition-2/posts/02-minimal-rust-kernel/index.fa.md

View File

@@ -1,524 +0,0 @@
+++
title = "Un binaire Rust autoporté"
weight = 1
path = "fr/freestanding-rust-binary"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "3e87916b6c2ed792d1bdb8c0947906aef9013ac1"
# GitHub usernames of the people that translated this post
translators = ["AlexandreMarcq", "alaincao"]
+++
La première étape pour créer notre propre noyau de système d'exploitation est de créer un exécutable Rust qui ne relie pas la bibliothèque standard. Cela rend possible l'exécution du code Rust sur la ["bare machine"][machine nue] sans système d'exploitation sous-jacent.
[machine nue]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
Ce blog est développé sur [GitHub]. Si vous avez un problème ou une question, veuillez ouvrir une issue. Vous pouvez aussi laisser un commentaire [en bas de page]. Le code source complet de cet article est disponible sur la branche [`post-01`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[en bas de page]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## Introduction
Pour écrire un noyau de système d'exploitation, nous avons besoin d'un code qui ne dépend pas de fonctionnalités de système d'exploitation. Cela signifie que nous ne pouvons pas utiliser les fils d'exécution, les fichiers, la mémoire sur le tas, le réseau, les nombres aléatoires, la sortie standard ou tout autre fonctionnalité nécessitant une abstraction du système d'exploitation ou un matériel spécifique. Cela a du sens, étant donné que nous essayons d'écrire notre propre OS et nos propres pilotes.
Cela signifie que nous ne pouvons pas utiliser la majeure partie de la [bibliothèque standard de Rust]. Il y a néanmoins beaucoup de fonctionnalités de Rust que nous _pouvons_ utiliser. Par exemple, nous pouvons utiliser les [iterators], les [closures], le [pattern matching], l'[option] et le [result], le [string formatting], et bien sûr l'[ownership system]. Ces fonctionnalités permettent l'écriture d'un noyau d'une façon expressive et haut-niveau sans se soucier des [comportements indéfinis] ou de la [sécurité de la mémoire].
[option]: https://doc.rust-lang.org/core/option/
[result]:https://doc.rust-lang.org/core/result/
[bibliothèque standard de Rust]: https://doc.rust-lang.org/std/
[iterators]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[string formatting]: https://doc.rust-lang.org/core/macro.write.html
[ownership system]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[comportement non-défini]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[sécurité de la mémoire]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
Pour créer un noyau d'OS en Rust, nous devons créer un exécutable qui peut tourner sans système d'exploitation sous-jacent. Un tel exécutable est appelé “freestanding” (autoporté) ou “bare-metal”.
Cet article décrit les étapes nécessaires pour créer un exécutable Rust autoporté et explique pourquoi ces étapes sont importantes. Si vous n'êtes intéressé que par un exemple minimal, vous pouvez **[aller au résumé](#resume)**.
## Désactiver la Bibliothèque Standard
Par défaut, toutes les crates Rust sont liées à la bibliothèque standard, qui repose sur les fonctionnalités du système dexploitation telles que les fils d'exécution, les fichiers et la connectivité réseau. Elle est également liée à la bibliothèque standard C `libc`, qui interagit étroitement avec les services fournis par l'OS. Comme notre plan est d'écrire un système d'exploitation, nous ne pouvons pas utiliser des bibliothèques dépendant de l'OS. Nous devons donc désactiver l'inclusion automatique de la bibliothèque standard en utilisant l'[attribut `no std`].
[bibliothèque standard]: https://doc.rust-lang.org/std/
[attribut `no std`]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
Nous commençons par créer un nouveau projet d'application cargo. La manière la plus simple de faire est avec la ligne de commande :
```
cargo new blog_os --bin --edition 2018
```
J'ai nommé le projet `blog_os`, mais vous pouvez évidemment choisir le nom qu'il vous convient. Le flag `--bin` indique que nous voulons créer un exécutable (contrairement à une bibliothèque) et le flag `--edition 2018` indique que nous voulons utiliser l'[édition 2018] de Rust pour notre crate. Quand nous lançons la commande, cargo crée la structure de répertoire suivante pour nous :
[édition 2018]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
Le fichier `Cargo.toml` contient la configuration de la crate, par exemple le nom de la crate, l'auteur, le numéro de [versionnage sémantique] et les dépendances. Le fichier `src/main.rs` contient le module racine de notre crate et notre fonction `main`. Vous pouvez compiler votre crate avec `cargo build` et ensuite exécuter l'exécutable compilé `blog_os` dans le sous-dossier `target/debug`.
[versionnage sémantique]: https://semver.org/
### L'Attribut `no_std`
Pour l'instant, notre crate relie la bibliothèque standard implicitement. Désactivons cela en ajoutant l'[attribut `no std`] :
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
Quand nous essayons maintenant de compiler (avec `cargo build)`, l'erreur suivante se produit :
```
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
La raison est que la [macro `println`] fait partie de la bibliothèque standard, que nous ne pouvons plus utiliser. Nous ne pouvons donc plus afficher de texte avec. Cela est logique, car `println` écrit dans la [sortie standard], qui est un descripteur de fichier spécial fourni par le système d'eploitation.
[macro `println`]: https://doc.rust-lang.org/std/macro.println.html
[sortie standard]: https://fr.wikipedia.org/wiki/Flux_standard#Sortie_standard
Supprimons l'affichage et essayons à nouveau avec une fonction main vide :
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
Maintenant le compilateur a besoin d'une fonction `#[panic_handler]` et d'un _objet de langage_.
## Implémentation de Panic
L'attribut `panic_handler` définit la fonction que le compilateur doit appeler lorsqu'un [panic] arrive. La bibliothèque standard fournit sa propre fonction de gestion de panic mais dans un environnement `no_std`, nous avons besoin de le définir nous-mêmes :
[panic]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// dans main.rs
use core::panic::PanicInfo;
/// Cette fonction est appelée à chaque panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
Le [paramètre `PanicInfo`][PanicInfo] contient le fichier et la ligne où le panic a eu lieu et le message optionnel de panic. La fonction ne devrait jamais retourner quoi que ce soit, elle est donc marquée comme [fonction divergente] en retournant le [type “never”] `!`. Nous ne pouvons pas faire grand chose dans cette fonction pour le moment, nous bouclons donc indéfiniment.
[PanicInfo]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[fonction divergente]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
[type “never”]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## L'Objet de Langage `eh_personality`
Les objets de langage sont des fonctions et des types spéciaux qui sont requis par le compilateur de manière interne. Par exemple, le trait [`Copy`] est un objet de langage qui indique au compilateur quels types possèdent la [sémantique copy][`Copy`]. Quand nous regardons l'[implémentation][copy code] du code, nous pouvons voir qu'il possède l'attribut spécial `#[lang = copy]` qui le définit comme étant un objet de langage.
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[copy code]: https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299
Bien qu'il soit possible de fournir des implémentations personnalisées des objets de langage, cela ne devrait être fait qu'en dernier recours. La raison est que les objets de langages sont des détails d'implémentation très instables et qui ne sont même pas vérifiés au niveau de leur type (donc le compilateur ne vérifie même pas qu'une fonction possède les bons types d'arguments). Heureusement, il y a une manière plus robuste de corriger l'erreur d'objet de langage ci-dessus.
L'[objet de langage `eh_personality`] marque une fonction qui est utilisée pour l'implémentation du [déroulement de pile]. Par défaut, Rust utilise le déroulement de pile pour exécuter les destructeurs de chaque variable vivante sur la pile en cas de [panic]. Cela assure que toute la mémoire utilisée est libérée et permet au fil d'exécution parent d'attraper la panic et de continuer l'exécution. Le déroulement toutefois est un processus compliqué et nécessite des bibliothèques spécifiques à l'OS ([libunwind] pour Linux ou [gestion structurée des erreurs] pour Windows), nous ne voulons donc pas l'utiliser pour notre système d'exploitation.
[objet de langage `eh_personality`]: https://github.com/rust-lang/rust/blob/edb368491551a77d77a48446d4ee88b35490c565/src/libpanic_unwind/gcc.rs#L11-L45
[déroulement de pile]: https://docs.microsoft.com/fr-fr/cpp/cpp/exceptions-and-stack-unwinding-in-cpp?view=msvc-160
[libunwind]: https://www.nongnu.org/libunwind/
[gestion structurée des erreurs]: https://docs.microsoft.com/fr-fr/windows/win32/debug/structured-exception-handling
### Désactiver le Déroulement
Il y a d'autres cas d'utilisation pour lesquels le déroulement n'est pas souhaité. Rust offre donc une option pour [interrompre après un panic]. Cela désactive la génération de symboles de déroulement et ainsi réduit considérablement la taille de l'exécutable. Il y a de multiples endroit où nous pouvons désactiver le déroulement. Le plus simple est d'ajouter les lignes suivantes dans notre `Cargo.toml` :
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
Cela configure la stratégie de panic à `abort` pour le profil `dev` (utilisé pour `cargo build`) et le profil `release` (utilisé pour `cargo build --release`). Maintenant l'objet de langage `eh_personality` ne devrait plus être requis.
[interrompre après un panic]: https://github.com/rust-lang/rust/pull/32900
Nous avons dorénavant corrigé les deux erreurs ci-dessus. Toutefois, si nous essayons de compiler, une autre erreur apparaît :
```
> cargo build
error: requires `start` lang_item
```
L'objet de langage `start` manque à notre programme. Il définit le point d'entrée.
## L'attribut `start`
On pourrait penser que la fonction `main` est la première fonction appelée lorsqu'un programme est exécuté. Toutefois, la plupart des langages ont un [environnement d'exécution] qui est responsable des tâches telles que le ramassage des miettes (ex: dans Java) ou les fils d'exécution logiciel (ex: les goroutines dans Go). Cet environnement doit être appelé avant `main` puisqu'il a besoin de s'initialiser.
[environnement d'exécution]: https://fr.wikipedia.org/wiki/Environnement_d%27ex%C3%A9cution
Dans un exécutable Rust classique qui relie la bibliothèque standard, l'exécution commence dans une bibliothèque d'environnement d'exécution C appelé `crt0` (“C runtime zero”). Elle configure l'environnement pour une application C. Cela comprend la création d'une pile et le placement des arguments dans les bons registres. L'environnement d'exécution C appelle ensuite [le point d'entrée de l'environnement d'exécution de Rust][rt::lang_start], qui est marqué par l'objet de langage `start`. Rust possède un environnement d'exécution très minime, qui se charge de petites tâches telles que la configuration des guardes de dépassement de pile ou l'affichage de la trace d'appels lors d'un panic. L'environnement d'exécution finit par appeler la fonction `main`.
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
Notre exécutable autoporté n'a pas accès à l'environnement d'exécution de Rust ni à `crt0`. Nous avons donc besoin de définir notre propre point d'entrée. Implémenter l'objet de langage `start` n'aiderait pas car nous aurions toujours besoin de `crt0`. Nous avons plutôt besoin de réécrire le point d'entrée de `crt0` directement.
### Réécrire le Point d'Entrée
Pour indiquer au compilateur que nous ne voulons pas utiliser la chaîne de point d'entrée normale, nous ajoutons l'attribut `#![no_main]`.
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// Cette fonction est appelée à chaque panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
Vous remarquerez peut-être que nous avons retiré la fonction `main`. La raison est que la présence de cette fonction n'a pas de sens sans un environnement d'exécution sous-jacent qui l'appelle. À la place, nous réécrivons le point d'entrée du système d'exploitation avec notre propre fonction `_start` :
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
En utilisant l'attribut `#[no_mangle]`, nous désactivons la [décoration de nom] pour assurer que le compilateur Rust crée une fonction avec le nom `_start`. Sans cet attribut, le compilateur génèrerait un symbol obscure `_ZN3blog_os4_start7hb173fedf945531caE` pour donner un nom unique à chaque fonction. L'attribut est nécessaire car nous avons besoin d'indiquer le nom de la fonction de point d'entrée à l'éditeur de lien (*linker*) dans l'étape suivante.
Nous devons aussi marquer la fonction avec `extern C` pour indiquer au compilateur qu'il devrait utiliser la [convention de nommage] de C pour cette fonction (au lieu de la convention de nommage de Rust non-spécifiée). Cette fonction se nomme `_start` car c'est le nom par défaut des points d'entrée pour la plupart des systèmes.
[décoration de nom]: https://fr.wikipedia.org/wiki/D%C3%A9coration_de_nom
[convention de nommage]: https://fr.wikipedia.org/wiki/Convention_de_nommage
Le type de retour `!` signifie que la fonction est divergente, c-à-d qu'elle n'a pas le droit de retourner quoi que ce soit. Cela est nécessaire car le point d'entrée n'est pas appelé par une fonction, mais invoqué directement par le système d'exploitation ou par le chargeur d'amorçage. Donc au lieu de retourner une valeur, le point d'entrée doit invoquer l'[appel système `exit`] du système d'exploitation. Dans notre cas, arrêter la machine pourrait être une action convenable, puisqu'il ne reste rien d'autre à faire si un exécutable autoporté s'arrête. Pour l'instant, nous remplissons la condition en bouclant indéfiniement.
[appel système `exit`]: https://fr.wikipedia.org/wiki/Appel_syst%C3%A8me
Quand nous lançons `cargo build`, nous obtenons une erreur de _linker_.
## Erreurs de Linker
Le linker est un programme qui va transformer le code généré en exécutable. Comme le format de l'exécutable differt entre Linux, Windows et macOS, chaque système possède son propre linker qui lève une erreur différente. La cause fondamentale de cette erreur est la même : la configuration par défaut du linker part du principe que notre programme dépend de l'environnement d'exécution de C, ce qui n'est pas le cas.
Pour résoudre les erreurs, nous devons indiquer au linker qu'il ne doit pas inclure l'environnement d'exécution de C. Nous pouvons faire cela soit en passant un ensemble précis d'arguments, soit en compilant pour une cible bare metal.
### Compiler pour une Cible Bare Metal
Par défaut Rust essaie de compiler un exécutable qui est compatible avec l'environnment du système actuel. Par exemple, si vous utilisez Windows avec `x86_64`, Rust essaie de compiler un exécutable Windows `.exe` qui utilises des instructions `x86_64`. Cet environnement est appelé système "hôte".
Pour décrire plusieurs environnements, Rust utilise une chaîne de caractères appelée [_triplé cible_]. Vous pouvez voir le triplé cible de votre système hôte en lançant la commande `rustc --version --verbose` :
[_triplé cible_]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
La sortie ci-dessus provient d'un système Linux `x86_64`. Nous pouvons voir que le triplé `host` est `x86_64-unknown-linux-gnu`, qui inclut l'architecture du CPU (`x86_64`), le vendeur (`unknown`), le système d'exploitation (`linux`) et l'[ABI] (`gnu`).
[ABI]: https://fr.wikipedia.org/wiki/Application_binary_interface
En compilant pour notre triplé hôte, le compilateur Rust ainsi que le linker supposent qu'il y a un système d'exploitation sous-jacent comme Linux ou Windows qui utilise l'environnement d'exécution C par défaut, ce qui cause les erreurs de linker. Donc pour éviter ces erreurs, nous pouvons compiler pour un environnement différent sans système d'exploitation sous-jacent.
Un exemple d'un tel envrironnement est le triplé cible `thumbv7em-none-eabihf`, qui décrit un système [ARM] [embarqué]. Les détails ne sont pas importants, tout ce qui compte est que le triplé cible n'a pas de système d'exploitation sous-jacent, ce qui est indiqué par le `none` dans le triplé cible. Pour pouvoir compiler pour cette cible, nous avons besoin de l'ajouter dans rustup :
[embarqué]: https://fr.wikipedia.org/wiki/Syst%C3%A8me_embarqu%C3%A9
[ARM]: https://fr.wikipedia.org/wiki/Architecture_ARM
```
rustup target add thumbv7em-none-eabihf
```
Cela télécharge une copie de la bibliothèque standard (et core) pour le système. Maintenant nous pouvons compiler notre exécutable autoporté pour cette cible :
```
cargo build --target thumbv7em-none-eabihf
```
En donnant un argument `--target`, nous effectuons une [compilation croisée][cross_compile] de notre exécutable pour un système bare metal. Comme le système cible n'a pas de système d'exploitation, le linker n'essaie pas de lier l'environnement d'exécution C et notre compilation réussit sans erreur de linker.
[cross compile]: https://en.wikipedia.org/wiki/Cross_compiler
C'est l'approche que nous allons utiliser pour construire notre noyau d'OS. Plutôt que `thumbv7em-none-eabihf`, nous allons utiliser une [cible personnalisée][custom target] qui décrit un environnement bare metal `x86_64`. Les détails seront expliqués dans le prochain article.
[custom target]: https://doc.rust-lang.org/rustc/targets/custom.html
### Arguments du Linker
Au lieu de compiler pour un système bare metal, il est aussi possible de résoudre les erreurs de linker en passant un ensemble précis d'arguments au linker. Ce n'est pas l'approche que nous allons utiliser pour notre noyau. Cette section est donc optionnelle et fournis uniquement à titre de complétude. Cliquez sur _"Arguments du Linker"_ ci-dessous pour montrer le contenu optionel.
<details>
<summary>Arguments du Linker</summary>
Dans cette section nous allons parler des erreurs de linker qui se produisent sur Linux, Windows et macOS. Nous allons aussi apprendre à résoudre ces erreurs en passant des arguments complémentaires au linker. À noter que le format de l'exécutable et le linker diffèrent entre les systèmes d'exploitation. Il faut donc un ensemble d'arguments différent pour chaque système.
#### Linux
Sur Linux, voici l'erreur de linker qui se produit (raccourcie) :
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
Le problème est que le linker inclut par défaut la routine de démarrage de l'environnement d'exécution de C, qui est aussi appelée `_start`. Elle requiert des symboles de la bibliothèque standard de C `libc` que nous n'incluons pas à cause de l'attribut `no_std`. Le linker ne peut donc pas résoudre ces références. Pour résoudre cela, nous pouvons indiquer au linker qu'il ne devrait pas lier la routine de démarrage de C en passant l'argument `-nostartfiles`.
Une façon de passer des attributs au linker via cargo est la commande `cargo rustc`. Cette commande se comporte exactement comme `cargo build`, mais permet aussi de donner des options à `rustc`, le compilateur Rust sous-jacent. `rustc` possède le flag `-C link-arg`, qui donne un argument au linker. Combinés, notre nouvelle commande ressemble à ceci :
```
cargo rustc -- -C link-arg=-nostartfiles
```
Dorénavant notre crate compile en tant qu'exécutable Linux autoporté !
Nous n'avions pas besoin de spécifier le nom de notre point d'entrée de façon explicite car le linker cherche par défaut une fonction nommée `_start`.
#### Windows
Sur Windows, une erreur de linker différente se produit (raccourcie) :
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
Cette erreur signifie que le linker ne peut pas trouver le point d'entrée. Sur Windows, le nom par défaut du point d'entrée [dépend du sous-système utilisé][windows-subsystems]. Pour le sous-système `CONSOLE`, le linker cherche une fonction nommée `mainCRTStartup` et pour le sous-système `WINDOWS`, il cherche une fonction nomée `WinMainCRTStartup`. Pour réécrire la valeur par défaut et indiquer au linker de chercher notre fonction `_start` à la place, nous pouvons donner l'argument `/ENTRY` au linker :
[windows-subsystems]: https://docs.microsoft.com/fr-fr/cpp/build/reference/entry-entry-point-symbol?view=msvc-160
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
Vu le format d'argument différent nous pouvons clairement voir que le linker Windows est un programme totalement différent du linker Linux.
Maintenant une erreur de linker différente se produit :
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
Cette erreur se produit car les exécutables Windows peuvent utiliser différents [sous-systèmes][windows-subsystems]. Pour les programmes normaux, ils sont inférés en fonction du nom du point d'entrée : s'il est nommé `main`, le sous-système `CONSOLE` est utilisé. Si le point d'entrée est nommé `WinMain`, alors le sous-sytème `WINDOWS` est utilisé. Comme notre fonction `_start` possède un nom différent, nous devons préciser le sous-système explicitement :
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
Ici nous utilisons le sous-système `CONSOLE`, mais le sous-système `WINDOWS` pourrait fonctionner aussi. Au lieu de donner `-C link-arg` plusieurs fois, nous utilisons `-C link-args` qui utilise des arguments séparés par des espaces.
Avec cette commande, notre exécutable devrait compiler avec succès sous Windows.
#### macOS
Sur macOS, voici l'erreur de linker qui se produit (raccourcie) :
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
Cette erreur nous indique que le linker ne peut pas trouver une fonction de point d'entrée avec le nom par défaut `main` (pour une quelconque raison, toutes les fonctions sur macOS sont précédées de `_`). Pour configurer le point d'entrée sur notre fonction `_start`, nous donnons l'argument `-e` au linker :
```
cargo rustc -- -C link-args="-e __start"
```
L'argument `-e` spécifie le nom de la fonction de point d'entrée. Comme toutes les fonctions ont un préfixe supplémentaire `_` sur macOS, nous devons configurer le point d'entrée comme étant `__start` au lieu de `_start`.
Maintenant l'erreur de linker suivante se produit :
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS [ne supporte pas officiellement les bibliothèques liées de façon statique] et necéessite que les programmes lient la bibliothèque `libSystem` par défaut. Pour réécrire ceci et lier une bibliothèque statique, nous donnons l'argument `-static` au linker :
[ne supporte pas officiellement les bibliothèques liées de façon statique]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
Cela ne suffit toujours pas, une troisième erreur de linker se produit :
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
Cette erreur se produit car les programmes sous macOS lient `crt0` (“C runtime zero”) par défaut. Ceci est similaire à l'erreur que nous avions eu sous Linux et peut aussi être résolue en ajoutant l'argument `-nostartfiles` au linker :
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
Maintenant notre programme compile avec succès sous macOS.
#### Unifier les Commandes de Compilation
À cet instant nous avons différentes commandes de compilation en fonction de la plateforme hôte, ce qui n'est pas idéal. Pour éviter cela, nous pouvons créer un ficher nommé `.cargo/config.toml` qui contient les arguments spécifiques aux plateformes :
```toml
# dans .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
La clé `rustflags` contient des arguments qui sont automatiquement ajoutés à chaque appel de `rustc`. Pour plus d'informations sur le fichier `.cargo/config.toml`, allez voir la [documentation officielle](https://doc.rust-lang.org/cargo/reference/config.html)
Maintenant notre programme devrait être compilable sur les trois plateformes avec un simple `cargo build`.
#### Devriez-vous Faire Ça ?
Bien qu'il soit possible de compiler un exécutable autoporté pour Linux, Windows et macOS, ce n'est probablement pas une bonne idée. La raison est que notre exécutable s'attend toujours à trouver certaines choses, par exemple une pile initialisée lorsque la fonction `_start` est appelée. Sans l'environnement d'exécution C, certaines de ces conditions peuvent ne pas être remplies, ce qui pourrait faire planter notre programme, avec par exemple une erreur de segmentation.
Si vous voulez créer un exécutable minimal qui tourne sur un système d'exploitation existant, include `libc` et mettre l'attribut `#[start]` come décrit [ici](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html) semble être une meilleure idée.
</details>
## Résumé
Un exécutable Rust autoporté minimal ressemble à ceci :
`src/main.rs`:
```rust
#![no_std] // ne pas lier la bibliothèque standard Rust
#![no_main] // désactiver tous les points d'entrée au niveau de Rust
use core::panic::PanicInfo;
#[no_mangle] // ne pas décorer le nom de cette fonction
pub extern "C" fn _start() -> ! {
// cette fonction est le point d'entrée, comme le linker cherche une fonction
// nomée `_start` par défaut
loop {}
}
/// Cette fonction est appelée à chaque panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`:
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# le profile utilisé pour `cargo build`
[profile.dev]
panic = "abort" # désactive le déroulement de la pile lors d'un panic
# le profile utilisé pour `cargo build --release`
[profile.release]
panic = "abort" # désactive le déroulement de la pile lors d'un panic
```
Pour compiler cet exécutable, nous devons compiler pour une cible bare metal telle que `thumbv7em-none-eabihf` :
```
cargo build --target thumbv7em-none-eabihf
```
Sinon, nous pouvons aussi compiler pour le système hôte en donnant des arguments supplémentaires pour le linker :
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
À noter que ceci est juste un exemple minimal d'un exécutable Rust autoporté. Cet exécutable s'attend à de nombreuses choses, comme par exemple le fait qu'une pile soit initialisée lorsque la fonction `_start` est appelée. **Donc pour une réelle utilisation d'un tel exécutable, davantages d'étapes sont requises.**
## Et ensuite ?
Le [poste suivant][next post] explique les étapes nécessaires pour transformer notre exécutable autoporté minimal en noyau de système d'opération. Cela comprend la création d'une cible personnalisée, l'intégration de notre exécutable avec un chargeur d'amorçage et l'apprentissage de comment imprimer quelque chose sur l'écran.
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.md

View File

@@ -1,531 +0,0 @@
+++
title = "フリースタンディングな Rust バイナリ"
weight = 1
path = "ja/freestanding-rust-binary"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "6f1f87215892c2be12c6973a6f753c9a25c34b7e"
# GitHub usernames of the people that translated this post
translators = ["JohnTitor"]
+++
私達自身のオペレーティングシステム(以下、OS)カーネルを作っていく最初のステップは標準ライブラリとリンクしない Rust の実行可能ファイルをつくることです。これにより、基盤となる OS がない[ベアメタル][bare metal]上で Rust のコードを実行することができるようになります。
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。また[こちら][comments]にコメントを残すこともできます。この記事の完全なソースコードは[`post-01` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[comments]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## 導入
OS カーネルを書くためには、いかなる OS の機能にも依存しないコードが必要となります。つまり、スレッドやヒープメモリ、ネットワーク、乱数、標準出力、その他 OS による抽象化や特定のハードウェアを必要とする機能は使えません。私達は自分自身で OS とそのドライバを書こうとしているので、これは理にかなっています。
これは [Rust の標準ライブラリ][Rust standard library]をほとんど使えないということを意味しますが、それでも私達が使うことのできる Rust の機能はたくさんあります。例えば、[イテレータ][iterators]や[クロージャ][closures]、[パターンマッチング][pattern matching]、 [`Option`][option] や [`Result`][result] 型に[文字列フォーマット][string formatting]、そしてもちろん[所有権システム][ownership system]を使うことができます。これらの機能により、[未定義動作][undefined behavior]や[メモリ安全性][memory safety]を気にせずに、高い水準で表現力豊かにカーネルを書くことができます。
[option]: https://doc.rust-lang.org/core/option/
[result]:https://doc.rust-lang.org/core/result/
[Rust standard library]: https://doc.rust-lang.org/std/
[iterators]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[string formatting]: https://doc.rust-lang.org/core/macro.write.html
[ownership system]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[undefined behavior]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[memory safety]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
Rust で OS カーネルを書くには、基盤となる OS なしで動く実行環境をつくる必要があります。そのような実行環境はフリースタンディング環境やベアメタルのように呼ばれます。
この記事では、フリースタンディングな Rust のバイナリをつくるために必要なステップを紹介し、なぜそれが必要なのかを説明します。もし最小限の説明だけを読みたいのであれば **[概要](#summary)** まで飛ばしてください。
## 標準ライブラリの無効化
デフォルトでは、全ての Rust クレートは[標準ライブラリ][standard library]にリンクされています。標準ライブラリはスレッドやファイル、ネットワークのような OS の機能に依存しています。また OS と密接な関係にある C の標準ライブラリ(`libc`)にも依存しています。私達の目的は OS を書くことなので、 OS 依存のライブラリを使うことはできません。そのため、 [`no_std` attribute] を使って標準ライブラリが自動的にリンクされるのを無効にします。
[standard library]: https://doc.rust-lang.org/std/
[`no_std` attribute]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
新しい Cargo プロジェクトをつくるところから始めましょう。もっとも簡単なやり方はコマンドラインで以下を実行することです。
```bash
cargo new blog_os --bin --edition 2018
```
プロジェクト名を `blog_os` としましたが、もちろんお好きな名前をつけていただいても大丈夫です。`--bin`フラグは実行可能バイナリを作成することを、 `--edition 2018` は[2018エディション][2018 edition]を使用することを明示的に指定します。コマンドを実行すると、 Cargoは以下のようなディレクトリ構造を作成します:
[2018 edition]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```bash
blog_os
├── Cargo.toml
└── src
└── main.rs
```
`Cargo.toml` にはクレートの名前や作者名、[セマンティックバージョニング][semantic version]に基づくバージョンナンバーや依存関係などが書かれています。`src/main.rs` には私達のクレートのルートモジュールと `main` 関数が含まれています。`cargo build` コマンドでこのクレートをコンパイルして、 `target/debug` ディレクトリの中にあるコンパイルされた `blog_os` バイナリを実行することができます。
[semantic version]: https://semver.org/
### `no_std` Attribute
今のところ私達のクレートは暗黙のうちに標準ライブラリをリンクしています。[`no_std` attribute]を追加してこれを無効にしてみましょう:
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
(`cargo build` を実行して)ビルドしようとすると、次のようなエラーが発生します:
```bash
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
これは [`println` マクロ][`println` macro]が標準ライブラリに含まれているためです。`no_std` で標準ライブラリを無効にしたので、何かをプリントすることはできなくなりました。`println` は標準出力に書き込むのでこれは理にかなっています。[標準出力][standard output]は OS によって提供される特別なファイル記述子であるためです。
[`println` macro]: https://doc.rust-lang.org/std/macro.println.html
[standard output]: https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29
では、 `println` を削除し `main` 関数を空にしてもう一度ビルドしてみましょう:
```rust
// main.rs
#![no_std]
fn main() {}
```
```bash
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
この状態では `#[panic_handler]` 関数と `language item` がないというエラーが発生します。
## Panic の実装
`panic_handler` attribute は[パニック]が発生したときにコンパイラが呼び出す関数を定義します。標準ライブラリには独自のパニックハンドラー関数がありますが、 `no_std` 環境では私達の手でそれを実装する必要があります:
[パニック]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// in main.rs
use core::panic::PanicInfo;
/// この関数はパニック時に呼ばれる
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
[`PanicInfo` パラメータ]には、パニックが発生したファイルと行、およびオプションでパニックメッセージが含まれます。この関数は戻り値を取るべきではないので、["never" 型(`!`)][“never” type]を返すことで[発散する関数][diverging function]となります。今のところこの関数でできることは多くないので、無限にループするだけです。
[`PanicInfo` パラメータ]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[diverging function]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
[“never” type]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## `eh_personality` Language Item
language item はコンパイラによって内部的に必要とされる特別な関数や型です。例えば、[`Copy`] トレイトはどの型が[コピーセマンティクス][`Copy`]を持っているかをコンパイラに伝える language item です。[実装][copy code]を見てみると、 language item として定義されている特別な `#[lang = "copy"]` attribute を持っていることが分かります。
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[copy code]: https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299
独自に language item を実装することもできますが、これは最終手段として行われるべきでしょう。というのも、language item は非常に不安定な実装であり型検査も行われないからです(なので、コンパイラは関数が正しい引数の型を取っているかさえ検査しません)。幸い、上記の language item のエラーを修正するためのより安定した方法があります。
[`eh_personality` language item] は[スタックアンワインド][stack unwinding] を実装するための関数を定義します。デフォルトでは、パニックが起きた場合には Rust はアンワインドを使用してすべてのスタックにある変数のデストラクタを実行します。これにより、使用されている全てのメモリが確実に解放され、親スレッドはパニックを検知して実行を継続できます。しかしアンワインドは複雑であり、いくつかの OS 特有のライブラリ(例えば、Linux では [libunwind] 、Windows では[構造化例外][structured exception handling])を必要とするので、私達の OS には使いたくありません。
[`eh_personality` language item]: https://github.com/rust-lang/rust/blob/edb368491551a77d77a48446d4ee88b35490c565/src/libpanic_unwind/gcc.rs#L11-L45
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
[libunwind]: https://www.nongnu.org/libunwind/
[structured exception handling]: https://docs.microsoft.com/en-us/windows/win32/debug/structured-exception-handling
### アンワインドの無効化
他にもアンワインドが望ましくないユースケースがあります。そのため、Rust には代わりに[パニックで中止する][abort on panic]オプションがあります。これにより、アンワインドのシンボル情報の生成が無効になり、バイナリサイズが大幅に削減されます。アンワインドを無効にする方法は複数あります。もっとも簡単な方法は、`Cargo.toml` に次の行を追加することです:
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
これは dev プロファイル(`cargo build` に使用される)と release プロファイル(`cargo build --release` に使用される)の両方でパニックで中止するようにするための設定です。これで `eh_personality` language item が不要になりました。
[abort on panic]: https://github.com/rust-lang/rust/pull/32900
これで上の2つのエラーを修正しました。しかし、コンパイルしようとすると別のエラーが発生します:
```bash
> cargo build
error: requires `start` lang_item
```
私達のプログラムにはエントリポイントを定義する `start` language item がありません。
## `start` attribute
`main` 関数はプログラムを実行したときに最初に呼び出される関数であると思うかもしれません。しかし、ほとんどの言語には[ランタイムシステム][runtime system]があり、これはガベージコレクション(Java など)やソフトウェアスレッド(Go のゴルーチン)などを処理します。ランタイムは自身を初期化する必要があるため、`main` 関数の前に呼び出す必要があります。これにはスタック領域の作成と正しいレジスタへの引数の配置が含まれます。
[runtime system]: https://en.wikipedia.org/wiki/Runtime_system
標準ライブラリをリンクする一般的な Rust バイナリでは、`crt0` ("C runtime zero")と呼ばれる C のランタイムライブラリで実行が開始され、C アプリケーションの環境が設定されます。その後 C ランタイムは、`start` language item で定義されている [Rust ランタイムのエントリポイント][rt::lang_start]を呼び出します。Rust にはごくわずかなランタイムしかありません。これは、スタックオーバーフローを防ぐ設定やパニック時のバックトレースの表示など、いくつかの小さな処理を行います。最後に、ランタイムは `main` 関数を呼び出します。
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
私達のフリースタンディングな実行可能ファイルは今のところ Rust ランタイムと `crt0` へアクセスできません。なので、私達は自身でエントリポイントを定義する必要があります。`start` language item を実装することは `crt0` を必要とするのでここではできません。代わりに `crt0` エントリポイントを直接上書きしなければなりません。
### エントリポイントの上書き
Rust コンパイラに通常のエントリポイントを使いたくないことを伝えるために、`#![no_main]` attribute を追加します。
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`main` 関数を削除したことに気付いたかもしれません。`main` 関数を呼び出す基盤となるランタイムなしには置いていても意味がないからです。代わりに、OS のエントリポイントを独自の `_start` 関数で上書きしていきます:
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
Rust コンパイラが `_start` という名前の関数を実際に出力するように、`#[no_mangle]` attributeを用いて[名前修飾][name mangling]を無効にします。この attribute がないと、コンパイラはすべての関数にユニークな名前をつけるために、 `_ZN3blog_os4_start7hb173fedf945531caE` のようなシンボルを生成します。次のステップでエントリポイントとなる関数の名前をリンカに伝えるため、この属性が必要となります。
また、(指定されていない Rust の呼び出し規約の代わりに)この関数に [C の呼び出し規約][C calling convention]を使用するようコンパイラに伝えるために、関数を `extern "C"` として定義する必要があります。`_start`という名前をつける理由は、これがほとんどのシステムのデフォルトのエントリポイント名だからです。
[name mangling]: https://en.wikipedia.org/wiki/Name_mangling
[C calling convention]: https://en.wikipedia.org/wiki/Calling_convention
戻り値の型である `!` は関数が発散している、つまり値を返すことができないことを意味しています。エントリポイントはどの関数からも呼び出されず、OS またはブートローダから直接呼び出されるので、これは必須です。なので、値を返す代わりに、エントリポイントは例えば OS の [`exit` システムコール][`exit` system call]を呼び出します。今回はフリースタンディングなバイナリが返されたときマシンをシャットダウンするようにすると良いでしょう。今のところ、私達は無限ループを起こすことで要件を満たします。
[`exit` system call]: https://en.wikipedia.org/wiki/Exit_(system_call)
`cargo build` を実行すると、見づらいリンカエラーが発生します。
## リンカエラー
リンカは、生成されたコードを実行可能ファイルに紐付けるプログラムです。実行可能ファイルの形式は Linux や Windows、macOS でそれぞれ異なるため、各システムにはそれぞれ異なるエラーを発生させる独自のリンカがあります。エラーの根本的な原因は同じです。リンカのデフォルト設定では、プログラムが C ランタイムに依存していると仮定していますが、実際にはしていません。
エラーを回避するためにはリンカに C ランタイムに依存しないことを伝える必要があります。これはリンカに一連の引数を渡すか、ベアメタルターゲット用にビルドすることで可能となります。
### ベアメタルターゲット用にビルドする
デフォルトでは、Rust は現在のシステム環境に合った実行可能ファイルをビルドしようとします。例えば、`x86_64` で Windows を使用している場合、Rust は `x86_64` 用の `.exe` Windows 実行可能ファイルをビルドしようとします。このような環境は「ホスト」システムと呼ばれます。
様々な環境を表現するために、Rust は [_target triple_] という文字列を使います。`rustc --version --verbose` を実行すると、ホストシステムの target triple を確認できます:
[_target triple_]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
```bash
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
上記の出力は `x86_64` の Linux によるものです。`host``x86_64-unknown-linux-gnu` です。これには CPU アーキテクチャ(`x86_64`)、ベンダー(`unknown`)、OS(`Linux`)、そして [ABI] (`gnu`)が含まれています。
[ABI]: https://en.wikipedia.org/wiki/Application_binary_interface
ホストの triple 用にコンパイルすることで、Rust コンパイラとリンカは、デフォルトで C ランタイムを使用する Linux や Windows のような基盤となる OS があると想定し、それによってリンカエラーが発生します。なのでリンカエラーを回避するために、基盤となる OS を使用せずに異なる環境用にコンパイルします。
このようなベアメタル環境の例としては、`thumbv7em-none-eabihf` target triple があります。これは、[組込みシステム][embedded]を表しています。詳細は省きますが、重要なのは `none` という文字列からわかるように、 この target triple に基盤となる OS がないことです。このターゲット用にコンパイルできるようにするには、 rustup にこれを追加する必要があります:
[embedded]: https://en.wikipedia.org/wiki/Embedded_system
```bash
rustup target add thumbv7em-none-eabihf
```
これにより、この target triple 用の標準(およびコア)ライブラリのコピーがダウンロードされます。これで、このターゲット用にフリースタンディングな実行可能ファイルをビルドできます:
```bash
cargo build --target thumbv7em-none-eabihf
```
`--target` 引数を渡すことで、ベアメタルターゲット用に実行可能ファイルを[クロスコンパイル][cross compile]します。このターゲットシステムには OS がないため、リンカは C ランタイムをリンクしようとせず、ビルドはリンカエラーなしで成功します。
[cross compile]: https://en.wikipedia.org/wiki/Cross_compiler
これが私達の OS カーネルを書くために使うアプローチです。`thumbv7em-none-eabihf` の代わりに、`x86_64` のベアメタル環境を表す[カスタムターゲット][custom target]を使用することもできます。詳細は次のセクションで説明します。
[custom target]: https://doc.rust-lang.org/rustc/targets/custom.html
### リンカへの引数
ベアメタルターゲット用にコンパイルする代わりに、特定の引数のセットをリンカにわたすことでリンカエラーを回避することもできます。これは私達がカーネルに使用するアプローチではありません。したがって、このセクションはオプションであり、選択肢を増やすために書かれています。表示するには以下の「リンカへの引数」をクリックしてください。
<details>
<summary>リンカへの引数</summary>
このセクションでは、Linux、Windows、および macOS で発生するリンカエラーについてと、リンカに追加の引数を渡すことによってそれらを解決する方法を説明します。実行可能ファイルの形式とリンカは OS によって異なるため、システムごとに異なる引数のセットが必要です。
#### Linux
Linux では以下のようなエラーが発生します(抜粋):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
問題は、デフォルトで C ランタイムの起動ルーチンがリンカに含まれていることです。これは `_start` とも呼ばれます。`no_std` attribute により、C 標準ライブラリ `libc` のいくつかのシンボルが必要となります。なので、リンカはこれらの参照を解決できません。これを解決するために、リンカに `-nostartfiles` フラグを渡して、C の起動ルーチンをリンクしないようにします。
Cargo を通してリンカの attribute を渡す方法の一つに、`cargo rustc` コマンドがあります。このコマンドは `cargo build` と全く同じように動作しますが、基本となる Rust コンパイラである `rustc` にオプションを渡すことができます。`rustc` にはリンカに引数を渡す `-C link-arg` フラグがあります。新しいビルドコマンドは次のようになります:
```bash
cargo rustc -- -C link-arg=-nostartfiles
```
これで crate を Linux 上で独立した実行ファイルとしてビルドできます!
リンカはデフォルトで `_start` という名前の関数を探すので、エントリポイントとなる関数の名前を明示的に指定する必要はありません。
#### Windows
Windows では別のリンカエラーが発生します(抜粋):
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
"entry point must be defined" というエラーは、リンカがエントリポイントを見つけられていないことを意味します。Windows では、デフォルトのエントリポイント名は[使用するサブシステム][windows-subsystems]によって異なります。`CONSOLE` サブシステムの場合、リンカは `mainCRTStartup` という名前の関数を探し、`WINDOWS` サブシステムの場合は、`WinMainCRTStartup` という名前の関数を探します。デフォルトの動作を無効にし、代わりに `_start` 関数を探すようにリンカに指示するには、`/ENTRY` 引数をリンカに渡します:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```bash
cargo rustc -- -C link-arg=/ENTRY:_start
```
引数の形式が異なることから、Windows のリンカは Linux のリンカとは全く異なるプログラムであることが分かります。
これにより、別のリンカエラーが発生します:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
このエラーは Windows での実行可能ファイルが異なる [subsystems][windows-subsystems] を使用することができるために発生します。通常のプログラムでは、エントリポイント名に基づいて推定されます。エントリポイントが `main` という名前の場合は `CONSOLE` サブシステムが使用され、エントリポイント名が `WinMain` である場合には `WINDOWS` サブシステムが使用されます。`_start` 関数は別の名前を持っているので、サブシステムを明示的に指定する必要があります:
This error occurs because Windows executables can use different [subsystems][windows-subsystems]. For normal programs they are inferred depending on the entry point name: If the entry point is named `main`, the `CONSOLE` subsystem is used, and if the entry point is named `WinMain`, the `WINDOWS` subsystem is used. Since our `_start` function has a different name, we need to specify the subsystem explicitly:
```bash
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
ここでは `CONSOLE` サブシステムを使用しますが、`WINDOWS` サブシステムを使うこともできます。`-C link-arg` を複数渡す代わりに、スペースで区切られたリストを引数として取る `-C link-args` を渡します。
このコマンドで、実行可能ファイルが Windows 上で正しくビルドされます。
#### macOS
macOS では次のようなリンカエラーが発生します(抜粋):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
このエラーメッセージは、リンカがデフォルト名が `main` (いくつかの理由で、macOS 上ではすべての関数の前には `_` が付きます) であるエントリポイントとなる関数を見つけられないことを示しています。`_start` 関数をエントリポイントとして設定するには、`-e` というリンカ引数を渡します:
```bash
cargo rustc -- -C link-args="-e __start"
```
`-e` というフラグでエントリポイントとなる関数の名前を指定できます。macOS 上では全ての関数には `_` というプレフィックスが追加されるので、`_start` ではなく `__start` にエントリポイントを設定する必要があります。
これにより、次のようなリンカエラーが発生します:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS は[正式には静的にリンクされたバイナリをサポートしておらず][does not officially support statically linked binaries]、プログラムはデフォルトで `libSystem` ライブラリにリンクされる必要があります。これを無効にして静的バイナリをリンクするには、`-static` フラグをリンカに渡します:
[does not officially support statically linked binaries]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```bash
cargo rustc -- -C link-args="-e __start -static"
```
これでもまだ十分ではありません、3つ目のリンカエラーが発生します:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
このエラーは、macOS 上のプログラムがデフォルトで `crt0` ("C runtime zero") にリンクされるために発生します。これは Linux 上で起きたエラーと似ており、`-nostartfiles` というリンカ引数を追加することで解決できます:
```bash
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
これで 私達のプログラムを macOS 上で正しくビルドできます。
#### ビルドコマンドの統一
現時点では、ホストプラットフォームによって異なるビルドコマンドを使っていますが、これは理想的ではありません。これを回避するために、プラットフォーム固有の引数を含む `.cargo/config.toml` というファイルを作成します:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
`rustflags` には `rustc` を呼び出すたびに自動的に追加される引数が含まれています。`.cargo/config.toml` についての詳細は[公式のドキュメント][official documentation]を確認してください。
[official documentation]: https://doc.rust-lang.org/cargo/reference/config.html
これで私達のプログラムは3つすべてのプラットフォーム上で、シンプルに `cargo build` のみでビルドすることができるようになります。
#### 私達はこれをすべきですか?
これらの手順で Linux、Windows および macOS 用の独立した実行可能ファイルをビルドすることはできますが、おそらく良い方法ではありません。その理由は、例えば `_start` 関数が呼ばれたときにスタックが初期化されるなど、まだ色々なことを前提としているからです。C ランタイムがなければ、これらの要件のうちいくつかが満たされない可能性があり、セグメンテーション違反(segfault)などによってプログラムが失敗する可能性があります。
もし既存の OS 上で動作する最小限のバイナリを作成したいなら、`libc` を使って `#[start]` attribute を[ここ][no-stdlib]で説明するとおりに設定するのが良いでしょう。
[no-stdlib]: https://doc.rust-lang.org/1.16.0/book/no-stdlib.html
</details>
## 概要 {#summary}
最小限の独立した Rust バイナリは次のようになります:
`src/main.rs`:
```rust
#![no_std] // Rust の標準ライブラリにリンクしない
#![no_main] // 全ての Rust レベルのエントリポイントを無効にする
use core::panic::PanicInfo;
#[no_mangle] // この関数の名前修飾をしない
pub extern "C" fn _start() -> ! {
// リンカはデフォルトで `_start` という名前の関数を探すので、
// この関数がエントリポイントとなる
loop {}
}
/// この関数はパニック時に呼ばれる
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`:
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic
# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic
```
このバイナリをビルドするために、`thumbv7em-none-eabihf` のようなベアメタルターゲット用にコンパイルする必要があります:
```bash
cargo build --target thumbv7em-none-eabihf
```
あるいは、追加のリンカ引数を渡してホストシステム用にコンパイルすることもできます:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
これは独立した Rust バイナリの最小の例にすぎないことに注意してください。このバイナリは `_start` 関数が呼び出されたときにスタックが初期化されるなど、さまざまなことを前提としています。**そのため、このようなバイナリを実際に使用するには、より多くの手順が必要となります**。
## 次は?
[次の記事][next post]では、この独立したバイナリを最小限の OS カーネルにするために必要なステップを説明しています。カスタムターゲットの作成、実行可能ファイルとブートローダの組み合わせ、画面に何か文字を表示する方法について説明しています。
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.ja.md

View File

@@ -1,546 +0,0 @@
+++
title = "Rust로 'Freestanding 실행파일' 만들기"
weight = 1
path = "ko/freestanding-rust-binary"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "c1af4e31b14e562826029999b9ab1dce86396b93"
# GitHub usernames of the people that translated this post
translators = ["JOE1994", "Quqqu"]
+++
운영체제 커널을 만드는 첫 단계는 표준 라이브러리(standard library)를 링크하지 않는 Rust 실행파일을 만드는 것입니다. 이 실행파일은 운영체제가 없는 [bare metal] 시스템에서 동작할 수 있습니다.
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
이 블로그는 [GitHub 저장소][GitHub]에서 오픈 소스로 개발되고 있으니, 문제나 문의사항이 있다면 저장소의 'Issue' 기능을 이용해 제보해주세요. [페이지 맨 아래][at the bottom]에 댓글을 남기실 수도 있습니다. 이 포스트와 관련된 모든 소스 코드는 저장소의 [`post-01 브랜치`][post branch]에서 확인하실 수 있습니다.
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## 소개
운영체제 커널을 만드려면 운영체제에 의존하지 않는 코드가 필요합니다. 자세히 설명하자면, 스레드, 파일, 동적 메모리, 네트워크, 난수 생성기, 표준 출력 및 기타 운영체제의 추상화 또는 특정 하드웨어의 기능을 필요로 하는 것들은 전부 사용할 수 없다는 뜻입니다. 우리는 스스로 운영체제 및 드라이버를 직접 구현하려는 상황이니 어찌 보면 당연한 조건입니다.
운영체제에 의존하지 않으려면 [Rust 표준 라이브러리][Rust standard library]의 많은 부분을 사용할 수 없습니다.
그래도 우리가 이용할 수 있는 Rust 언어 자체의 기능들은 많이 남아 있습니다. 예를 들어 [반복자][iterators], [클로저][closures], [패턴 매칭][pattern matching], [option] / [result], [문자열 포맷 설정][string formatting], 그리고 [소유권 시스템][ownership system] 등이 있습니다. 이러한 기능들은 우리가 커널을 작성할 때 [undefined behavior]나 [메모리 안전성][memory safety]에 대한 걱정 없이 큰 흐름 단위의 코드를 작성하는 데에 집중할 수 있도록 해줍니다.
[option]: https://doc.rust-lang.org/core/option/
[result]:https://doc.rust-lang.org/core/result/
[Rust standard library]: https://doc.rust-lang.org/std/
[iterators]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[string formatting]: https://doc.rust-lang.org/core/macro.write.html
[ownership system]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[undefined behavior]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[memory safety]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
Rust로 운영체제 커널을 작성하려면, 운영체제 없이도 실행가능한 실행파일이 필요합니다. 이러한 실행파일은
보통 "freestanding 실행파일" 혹은 "bare-metal 실행파일" 이라고 불립니다.
이 포스트에서는 "freestanding 실행 파일" 을 만드는 데 필요한 것들을 여러 단계로 나누고, 각 단계가 왜 필요한지에 대해 설명해드립니다. 중간 과정은 생략하고 그저 최소한의 예제 코드만 확인하고 싶으시면 **[요약 섹션으로 넘어가시면 됩니다](#summary)**.
## Rust 표준 라이브러리 링크 해제하기
모든 Rust 프로그램들은 Rust 표준 라이브러리를 링크하는데, 이 라이브러리는 스레드, 파일, 네트워킹 등의 기능을 제공하기 위해 운영체제에 의존합니다. Rust 표준 라이브러리는 또한 C 표준 라이브러리인 `libc`에도 의존합니다 (`libc`는 운영체제의 여러 기능들을 이용합니다).
우리가 운영체제를 직접 구현하기 위해서는 운영체제를 이용하는 라이브러리들은 사용할 수 없습니다. 그렇기에 우선 [`no_std` 속성][`no_std` attribute]을 이용해 자동으로 Rust 표준 라이브러리가 링크되는 것을 막아야 합니다.
[standard library]: https://doc.rust-lang.org/std/
[`no_std` attribute]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
제일 먼저 아래의 명령어를 통해 새로운 cargo 애플리케이션 크레이트를 만듭니다.
```
cargo new blog_os --bin --edition 2018
```
프로젝트 이름은 `blog_os` 또는 원하시는 이름으로 정해주세요. `--bin` 인자는 우리가 cargo에게 실행 파일 (라이브러리와 대조됨)을 만들겠다고 알려주고, `--edition 2018` 인자는 cargo에게 우리가 [Rust 2018 에디션][2018 edition]을 사용할 것이라고 알려줍니다.
위 명령어를 실행하고 나면, cargo가 아래와 같은 크레이트 디렉토리를 만들어줍니다.
[2018 edition]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
크레이트 설정은 `Cargo.toml`에 전부 기록해야 합니다 (크레이트 이름, 크레이트 원작자, [semantic version] 번호, 의존 라이브러리 목록 등).
`src/main.rs` 파일에 크레이트 실행 시 맨 처음 호출되는 `main` 함수를 포함한 중추 모듈이 있습니다.
`cargo build` 명령어를 통해 크레이트를 빌드하면 `target/debug` 디렉토리에 `blog_os` 실행파일이 생성됩니다.
[semantic version]: https://semver.org/
### `no_std` 속성
현재 우리가 만든 크레이트는 암시적으로 Rust 표준 라이브러리를 링크합니다. 아래와 같이 [`no_std` 속성]을 이용해 더 이상 표준 라이브러리가 링크되지 않게 해줍니다.
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
이제 `cargo build` 명령어를 다시 실행하면 아래와 같은 오류 메세지가 뜰 것입니다:
```
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
이 오류가 뜨는 이유는 [`println` 매크로][`println` macro]를 제공하는 Rust 표준 라이브러리를 우리의 크레이트에 링크하지 않게 되었기 때문입니다.
`println`은 [표준 입출력][standard output] (운영체제가 제공하는 특별한 파일 서술자)으로 데이터를 쓰기 때문에, 우리는 이제 `println`을 이용해 메세지를 출력할 수 없습니다.
[`println` macro]: https://doc.rust-lang.org/std/macro.println.html
[standard output]: https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29
`println` 매크로 호출 코드를 지운 후 크레이트를 다시 빌드해봅시다.
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
오류 메세지를 통해 컴파일러가 `#[panic_handler]` 함수와 _language item_ 을 필요로 함을 확인할 수 있습니다.
## 패닉 (Panic) 시 호출되는 함수 구현하기
컴파일러는 [패닉][panic]이 일어날 경우 `panic_handler` 속성이 적용된 함수가 호출되도록 합니다. 표준 라이브러리는 패닉 시 호출되는 함수가 제공되지만, `no_std` 환경에서는 우리가 패닉 시 호출될 함수를 직접 설정해야 합니다.
[panic]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// in main.rs
use core::panic::PanicInfo;
/// 패닉이 일어날 경우, 이 함수가 호출됩니다.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
[`PanicInfo` 인자][PanicInfo]는 패닉이 일어난 파일명, 패닉이 파일 내 몇 번째 줄에서 일어났는지, 그리고 패닉시 전달된 메세지에 대한 정보를 가진 구조체입니다.
`panic` 함수는 절대로 반환하지 않기에 ["never" 타입][“never” type] `!`을 반환하도록 적어 컴파일러에게 이 함수가 [반환 함수][diverging function]임을 알립니다.
당장 이 함수에서 우리가 하고자 하는 일은 없기에 그저 함수가 반환하지 않도록 무한루프를 넣어줍니다.
[PanicInfo]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[diverging function]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
[“never” type]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## `eh_personality` Language Item
Language item은 컴파일러가 내부적으로 요구하는 특별한 함수 및 타입들을 가리킵니다. 예를 들어 [`Copy`] 트레잇은 어떤 타입들이 [_copy semantics_][`Copy`] 를 가지는지 컴파일러에게 알려주는 language item 입니다.
[`Copy` 트레잇이 구현된 코드][copy code]에 있는 `#[lang = "copy"]` 속성을 통해 이 트레잇이 language item으로 선언되어 있음을 확인할 수 있습니다.
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[copy code]: https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299
임의로 구현한 language item을 사용할 수는 있지만, 위험할 수도 있기에 주의해야 합니다.
그 이유는 language item의 구현 코드는 매우 자주 변경되어 불안정하며, language item에 대해서 컴파일러가 타입 체크 조차 하지 않습니다 (예시: language item 함수의 인자 타입이 정확한지 조차 체크하지 않습니다).
임의로 구현한 language item을 이용하는 것보다 더 안정적으로 위의 language item 오류를 고칠 방법이 있습니다.
[`eh_personality` language item]은 [스택 되감기 (stack unwinding)][stack unwinding]을 구현하는 함수를 가리킵니다. 기본적으로 Rust는 [패닉][panic]이 일어났을 때 스택 되감기를 통해 스택에 살아있는 각 변수의 소멸자를 호출합니다. 이를 통해 자식 스레드에서 사용 중이던 모든 메모리 리소스가 반환되고, 부모 스레드가 패닉에 대처한 후 계속 실행될 수 있게 합니다. 스택 되감기는 복잡한 과정으로 이루어지며 운영체제마다 특정한 라이브러리를 필요로 하기에 (예: Linux는 [libunwind], Windows는 [structured exception handling]), 우리가 구현할 운영체제에서는 이 기능을 사용하지 않을 것입니다.
[`eh_personality` language item]: https://github.com/rust-lang/rust/blob/edb368491551a77d77a48446d4ee88b35490c565/src/libpanic_unwind/gcc.rs#L11-L45
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
[libunwind]: https://www.nongnu.org/libunwind/
[structured exception handling]: https://docs.microsoft.com/en-us/windows/win32/debug/structured-exception-handling
### 스택 되감기를 해제하는 방법
스택 되감기가 불필요한 상황들이 여럿 있기에, Rust 언어는 [패닉 시 실행 종료][abort on panic] 할 수 있는 선택지를 제공합니다. 이는 스택 되감기에 필요한 심볼 정보 생성을 막아주어 실행 파일의 크기 자체도 많이 줄어들게 됩니다. 스택 되감기를 해제하는 방법은 여러가지 있지만, 가장 쉬운 방법은 `Cargo.toml` 파일에 아래의 코드를 추가하는 것입니다.
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
위의 코드를 통해 `dev` 빌드 (`cargo build` 실행)와 `release` 빌드 (`cargo build --release` 실행) 에서 모두 패닉 시 실행이 종료되도록 설정되었습니다.
이제 더 이상 컴파일러가 `eh_personality` language item을 필요로 하지 않습니다.
[abort on panic]: https://github.com/rust-lang/rust/pull/32900
위에서 본 오류들을 고쳤지만, 크레이트를 빌드하려고 하면 새로운 오류가 뜰 것입니다:
```
> cargo build
error: requires `start` lang_item
```
우리의 프로그램에는 프로그램 실행 시 최초 실행 시작 지점을 지정해주는 `start` language item이 필요합니다.
## `start` 속성
혹자는 프로그램 실행 시 언제나 `main` 함수가 가장 먼저 호출된다고 생각할지도 모릅니다. 대부분의 프로그래밍 언어들은 [런타임 시스템][runtime system]을 가지고 있는데, 이는 가비지 컬렉션 (예시: Java) 혹은 소프트웨어 스레드 (예시: GoLang의 goroutine) 등의 기능을 담당합니다.
이러한 런타임 시스템은 프로그램 실행 이전에 초기화 되어야 하기에 `main` 함수 호출 이전에 먼저 호출됩니다.
[runtime system]: https://en.wikipedia.org/wiki/Runtime_system
러스트 표준 라이브러리를 링크하는 전형적인 러스트 실행 파일의 경우, 프로그램 실행 시 C 런타임 라이브러리인 `crt0` (“C runtime zero”) 에서 실행이 시작됩니다. `crt0`는 C 프로그램의 환경을 설정하고 초기화하는 런타임 시스템으로, 스택을 만들고 프로그램에 주어진 인자들을 적절한 레지스터에 배치합니다. `crt0`가 작업을 마친 후 `start` language item으로 지정된 [Rust 런타임의 실행 시작 함수][rt::lang_start]를 호출합니다.
Rust는 최소한의 런타임 시스템을 가지며, 주요 기능은 스택 오버플로우 가드를 초기화하고 패닉 시 역추적 (backtrace) 정보를 출력하는 것입니다. Rust 런타임의 초기화 작업이 끝난 후에야 `main` 함수가 호출됩니다.
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
우리의 "freestanding 실행 파일" 은 Rust 런타임이나 `crt0`에 접근할 수 없기에, 우리가 직접 프로그램 실행 시작 지점을 지정해야 합니다.
`crt0``start` language item을 호출해주는 방식으로 동작하기에, `start` language item을 구현하고 지정하는 것만으로는 문제를 해결할 수 없습니다.
대신 우리가 직접 `crt0`의 시작 지점을 대체할 새로운 실행 시작 지점을 제공해야 합니다.
### 실행 시작 지점 덮어쓰기
`#![no_main]` 속성을 이용해 Rust 컴파일러에게 우리가 일반적인 실행 시작 호출 단계를 이용하지 않겠다고 선언합니다.
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// 패닉이 일어날 경우, 이 함수가 호출됩니다.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`main` 함수가 사라진 것을 눈치채셨나요? `main` 함수를 호출해주는 런타임 시스템이 없는 이상 `main` 함수의 존재도 더 이상 의미가 없습니다.
우리는 운영체제가 호출하는 프로그램 실행 시작 지점 대신 우리의 새로운 `_start` 함수를 실행 시작 지점으로 대체할 것입니다.
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
`#[no_mangle]` 속성을 통해 [name mangling]을 해제하여 Rust 컴파일러가 `_start` 라는 이름 그대로 함수를 만들도록 합니다. 이 속성이 없다면, 컴파일러가 각 함수의 이름을 고유하게 만드는 과정에서 이 함수의 실제 이름을 `_ZN3blog_os4_start7hb173fedf945531caE` 라는 이상한 이름으로 바꿔 생성합니다. 우리가 원하는 실제 시작 지점 함수의 이름을 정확히 알고 있어야 링커 (linker)에도 그 이름을 정확히 전달할 수 있기에 (후속 단계에서 진행) `#[no_mangle]` 속성이 필요합니다.
또한 우리는 이 함수에 `extern "C"`라는 표시를 추가하여 이 함수가 Rust 함수 호출 규약 대신에 [C 함수 호출 규약][C calling convention]을 사용하도록 합니다. 함수의 이름을 `_start`로 지정한 이유는 그저 런타임 시스템들의 실행 시작 함수 이름이 대부분 `_start`이기 때문입니다.
[name mangling]: https://en.wikipedia.org/wiki/Name_mangling
[C calling convention]: https://en.wikipedia.org/wiki/Calling_convention
`!` 반환 타입은 이 함수가 발산 함수라는 것을 의미합니다. 시작 지점 함수는 오직 운영체제나 부트로더에 의해서만 직접 호출됩니다. 따라서 시작 지점 함수는 반환하는 대신 운영체제의 [`exit` 시스템콜][`exit` system call]을 이용해 종료됩니다. 우리의 "freestanding 실행 파일" 은 실행 종료 후 더 이상 실행할 작업이 없기에, 시작 지점 함수가 작업을 마친 후 기기를 종료하는 것이 합리적입니다. 여기서는 일단 `!` 타입의 조건을 만족시키기 위해 무한루프를 넣어 줍니다.
[`exit` system call]: https://en.wikipedia.org/wiki/Exit_(system_call)
다시 `cargo build`를 실행하면, 끔찍한 _링커_ 오류를 마주하게 됩니다.
## 링커 오류
링커는 컴파일러가 생성한 코드들을 묶어 실행파일로 만드는 프로그램입니다. 실행 파일 형식은 Linux, Windows, macOS 마다 전부 다르기에 각 운영체제는 자신만의 링커가 있고 링커마다 다른 오류 메세지를 출력할 것입니다.
오류가 나는 근본적인 원인은 모두 동일한데, 링커는 주어진 프로그램이 C 런타임 시스템을 이용할 것이라고 가정하는 반면 우리의 크레이트는 그렇지 않기 때문입니다.
이 링커 오류를 해결하려면 링커에게 C 런타임을 링크하지 말라고 알려줘야 합니다. 두 가지 방법이 있는데, 하나는 링커에 특정 인자들을 주는 것이고, 또다른 하나는 크레이트 컴파일 대상 기기를 bare metal 기기로 설정하는 것입니다.
### Bare Metal 시스템을 목표로 빌드하기
기본적으로 Rust는 당신의 현재 시스템 환경에서 실행할 수 있는 실행파일을 생성하고자 합니다. 예를 들어 Windows `x86_64` 사용자의 경우, Rust는 `x86_64` 명령어 셋을 사용하는 `.exe` 확장자 실행파일을 생성합니다. 사용자의 기본 시스템 환경을 "호스트" 시스템이라고 부릅니다.
여러 다른 시스템 환경들을 표현하기 위해 Rust는 [_target triple_]이라는 문자열을 이용합니다. 현재 호스트 시스템의 target triple이 궁금하시다면 `rustc --version --verbose` 명령어를 실행하여 확인 가능합니다.
[_target triple_]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
위의 출력 내용은 `x86_64` Linux 시스템에서 얻은 것입니다. 호스트 target triple이 `x86_64-unknown-linux-gnu`으로 나오는데, 이는 CPU 아키텍쳐 정보 (`x86_64`)와 하드웨어 판매자 (`unknown`), 운영체제 (`linux`) 그리고 [응용 프로그램 이진 인터페이스 (ABI)][ABI] (`gnu`) 정보를 모두 담고 있습니다.
[ABI]: https://en.wikipedia.org/wiki/Application_binary_interface
우리의 호스트 시스템 triple을 위해 컴파일하는 경우, Rust 컴파일러와 링커는 Linux나 Windows와 같은 운영체제가 있다고 가정하고 또한 운영체제가 C 런타임 시스템을 사용할 것이라고 가정하기 때문에 링커 오류 메세지가 출력된 것입니다. 이런 링커 오류를 피하려면 운영체제가 없는 시스템 환경에서 코드가 구동하는 것을 목표로 컴파일해야 합니다.
운영체제가 없는 bare metal 시스템 환경의 한 예시로 `thumbv7em-none-eabihf` target triple이 있습니다 (이는 [임베디드][embedded] [ARM] 시스템을 가리킵니다). Target triple의 `none`은 시스템에 운영체제가 동작하지 않음을 의미하며, 이 target triple의 나머지 부분의 의미는 아직 모르셔도 괜찮습니다. 이 시스템 환경에서 구동 가능하도록 컴파일하려면 rustup에서 해당 시스템 환경을 추가해야 합니다.
[embedded]: https://en.wikipedia.org/wiki/Embedded_system
[ARM]: https://en.wikipedia.org/wiki/ARM_architecture
```
rustup target add thumbv7em-none-eabihf
```
위 명령어를 실행하면 해당 시스템을 위한 Rust 표준 라이브러리 및 코어 라이브러리를 설치합니다. 이제 해당 target triple을 목표로 하는 freestanding 실행파일을 만들 수 있습니다.
```
cargo build --target thumbv7em-none-eabihf
```
`--target` 인자를 통해 우리가 해당 bare metal 시스템을 목표로 [크로스 컴파일][cross compile]할 것이라는 것을 cargo에게 알려줍니다. 목표 시스템 환경에 운영체제가 없는 것을 링커도 알기 때문에 C 런타임을 링크하려고 시도하지 않으며 이제는 링커 에러 없이 빌드가 성공할 것입니다.
[cross compile]: https://en.wikipedia.org/wiki/Cross_compiler
우리는 이 방법을 이용하여 우리의 운영체제 커널을 빌드해나갈 것입니다. 위에서 보인 `thumbv7em-none-eabihf` 시스템 환경 대신 bare metal `x86_64` 시스템 환경을 묘사하는 [커스텀 시스템 환경][custom target]을 설정하여 빌드할 것입니다. 더 자세한 내용은 다음 포스트에서 더 설명하겠습니다.
[custom target]: https://doc.rust-lang.org/rustc/targets/custom.html
### 링커 인자
Bare metal 시스템을 목표로 컴파일하는 대신, 링커에게 특정 인자들을 추가로 주어 링커 오류를 해결하는 방법도 있습니다.
이 방법은 앞으로 우리가 작성해나갈 커널 코드를 빌드할 때는 사용하지 않을 것이지만, 더 알고싶어 하실 분들을 위해서 이 섹션을 준비했습니다.
아래의 _"링커 인자"_ 텍스트를 눌러 이 섹션의 내용을 확인하세요.
<details>
<summary>링커 인자</summary>
이 섹션에서는 Linux, Windows 그리고 macOS 각각의 운영체제에서 나타나는 링커 오류에 대해 다루고 각 운영체제마다 링커에 어떤 추가 인자들을 주어 링커 오류를 해결할 수 있는지 설명할 것입니다.
#### Linux
Linux 에서는 아래와 같은 링커 오류 메세지가 출력됩니다 (일부 생략됨):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
이 상황을 설명하자면 링커가 기본적으로 C 런타임의 실행 시작 루틴을 링크하는데, 이 루틴 역시 `_start`라는 이름을 가집니다. 이 `_start` 루틴은 C 표준 라이브러리 (`libc`)가 포함하는 여러 symbol들을 필요로 하지만, 우리는 `no_std` 속성을 이용해 크레이트에서 `libc`를 링크하지 않기 때문에 링커가 몇몇 symbol들의 출처를 찾지 못하여 위와 같은 링커 오류 메세지가 출력되는 것입니다. 이 문제를 해결하려면, 링커에게 `--nostartfiles` 인자를 전달하여 더 이상 링커가 C 런타임의 실행 시작 루틴을 링크하지 않도록 해야 합니다.
링커에 인자를 전달하는 한 방법은 `cargo rustc` 명령어를 이용하는 것입니다. 이 명령어는 `cargo build`와 유사하게 동작하나, `rustc`(Rust 컴파일러)에 직접 인자를 전달할 수 있게 해줍니다. `rustc``-C link-arg` 인자를 통해 링커에게 인자를 전달할 수 있게 해줍니다. 우리가 이용할 새로운 빌드 명령어는 아래와 같습니다:
```
cargo rustc -- -C link-arg=-nostartfiles
```
이제 우리의 크레이트가 성공적으로 빌드되고 Linux에서 동작하는 freestanding 실행파일이 생성됩니다!
우리는 위의 빌드 명령어에서 실행 시작 함수의 이름을 명시적으로 전달하지 않았는데, 그 이유는 링커가 기본적으로 `_start` 라는 이름의 함수를 찾아 그 함수를 실행 시작 함수로 이용하기 때문입니다.
#### Windows
Windows에서는 다른 링커 오류를 마주하게 됩니다 (일부 생략):
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
오류 메세지 "entry point must be defined"는 링커가 실행 시작 지점을 찾을 수 없다는 것을 알려줍니다. Windows에서는 기본 실행 시작 지점의 이름이 [사용 중인 서브시스템(subsystem)에 따라 다릅니다][windows-subsystems]. `CONSOLE` 서브시스템의 경우 링커가 `mainCRTStartup`이라는 함수를 실행 시작 지점으로 간주하고, `WINDOWS` 서브시스템의 경우 링커가 `WinMainCRTStartup`이라는 이름의 함수를 실행 시작 지점으로 간주합니다. 이러한 기본값을 변경하여 링커가 `_start`라는 이름의 함수를 실행 시작 지점으로 간주하도록 만드려면 링커에 `/ENTRY` 인자를 넘겨주어야 합니다:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
Linux에서와는 다른 인자 형식을 통해 Windows의 링커는 Linux의 링커와 완전히 다른 프로그램이라는 것을 유추할 수 있습니다.
이제 또 다른 링커 오류가 발생합니다:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
이 오류가 뜨는 이유는 Windows 실행파일들은 여러 가지 [서브시스템][windows-subsystems]을 사용할 수 있기 때문입니다. 일반적인 프로그램들의 경우, 실행 시작 지점 함수의 이름에 따라 어떤 서브시스템을 사용하는지 추론합니다: 실행 시작 지점의 이름이 `main`인 경우 `CONSOLE` 서브시스템이 사용 중이라는 것을 알 수 있으며, 실행 시작 지점의 이름이 `WinMain`인 경우 `WINDOWS` 서브시스템이 사용 중이라는 것을 알 수 있습니다. 우리는 `_start`라는 새로운 이름의 실행 시작 지점을 이용할 것이기에, 우리가 어떤 서브시스템을 사용할 것인지 인자를 통해 명시적으로 링커에게 알려줘야 합니다:
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
위 명령어에서는 `CONSOLE` 서브시스템을 서용했지만, `WINDOWS` 서브시스템을 적용해도 괜찮습니다. `-C link-arg` 인자를 반복해서 쓰는 대신, `-C link-args`인자를 이용해 여러 인자들을 빈칸으로 구분하여 전달할 수 있습니다.
이 명령어를 통해 우리의 실행 파일을 Windows에서도 성공적으로 빌드할 수 있을 것입니다.
#### macOS
macOS에서는 아래와 같은 링커 오류가 출력됩니다 (일부 생략):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
위 오류 메세지는 우리에게 링커가 실행 시작 지점 함수의 기본값 이름 `main`을 찾지 못했다는 것을 알려줍니다 (macOS에서는 무슨 이유에서인지 모든 함수들의 이름 맨 앞에 `_` 문자가 앞에 붙습니다). 실행 시작 지점 함수의 이름을 `_start`로 새롭게 지정해주기 위해 아래와 같이 링커 인자 `-e`를 이용합니다:
```
cargo rustc -- -C link-args="-e __start"
```
`-e` 인자를 통해 실행 시작 지점 함수 이름을 설정합니다. macOS에서는 모든 함수의 이름 앞에 추가로 `_` 문자가 붙기에, 실행 시작 지점 함수의 이름을 `_start` 대신 `__start`로 지정해줍니다.
이제 아래와 같은 링커 오류가 나타날 것입니다:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS는 [공식적으로는 정적으로 링크된 실행파일을 지원하지 않으며][does not officially support statically linked binaries], 기본적으로 모든 프로그램이 `libSystem` 라이브러리를 링크하도록 요구합니다. 이러한 기본 요구사항을 무시하고 정적으로 링크된 실행 파일을 만드려면 링커에게 `-static` 인자를 주어야 합니다:
[does not officially support statically linked binaries]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
아직도 충분하지 않았는지, 세 번째 링커 오류가 아래와 같이 출력됩니다:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
이 오류가 뜨는 이유는 macOS에서 모든 프로그램은 기본적으로 `crt0` (“C runtime zero”)를 링크하기 때문입니다. 이 오류는 우리가 Linux에서 봤던 오류와 유사한 것으로, 똑같이 링커에 `-nostartfiles` 인자를 주어 해결할 수 있습니다:
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
이제는 우리의 프로그램을 macOS에서 성공적으로 빌드할 수 있을 것입니다.
#### 플랫폼 별 빌드 명령어들을 하나로 통합하기
위에서 살펴본 대로 호스트 플랫폼 별로 상이한 빌드 명령어가 필요한데, `.cargo/config.toml` 이라는 파일을 만들고 플랫폼 마다 필요한 상이한 인자들을 명시하여 여러 빌드 명령어들을 하나로 통합할 수 있습니다.
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
`rustflags`에 포함된 인자들은 `rustc`가 실행될 때마다 자동적으로 `rustc`에 인자로 전달됩니다. `.cargo/config.toml`에 대한 더 자세한 정보는 [공식 안내 문서](https://doc.rust-lang.org/cargo/reference/config.html)를 통해 확인해주세요.
이제 `cargo build` 명령어 만으로 세 가지 플랫폼 어디에서도 우리의 프로그램을 성공적으로 빌드할 수 있습니다.
#### 이렇게 하는 것이 괜찮나요?
Linux, Windows 또는 macOS 위에서 동작하는 freestanding 실행파일을 빌드하는 것이 가능하긴 해도 좋은 방법은 아닙니다. 운영체제가 갖춰진 환경을 목표로 빌드를 한다면, 실행 파일 동작 시 다른 많은 조건들이 런타임에 의해 제공될 것이라는 가정 하에 빌드가 이뤄지기 때문입니다 (예: 실행 파일이 `_start` 함수가 호출되는 시점에 이미 스택이 초기화되어있을 것이라고 간주하고 작동합니다). C 런타임 없이는 실행 파일이 필요로 하는 조건들이 갖춰지지 않아 결국 세그멘테이션 오류가 나는 등 프로그램이 제대로 실행되지 못할 수 있습니다.
이미 존재하는 운영체제 위에서 동작하는 최소한의 실행 파일을 만들고 싶다면, `libc`를 링크하고 [이 곳의 설명](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html)에 따라 `#[start]` 속성을 설정하는 것이 더 좋은 방법일 것입니다.
</details>
## 요약 {#summary}
아래와 같은 최소한의 코드로 "freestanding" Rust 실행파일을 만들 수 있습니다:
`src/main.rs`:
```rust
#![no_std] // Rust 표준 라이브러리를 링크하지 않도록 합니다
#![no_main] // Rust 언어에서 사용하는 실행 시작 지점 (main 함수)을 사용하지 않습니다
use core::panic::PanicInfo;
#[no_mangle] // 이 함수의 이름을 mangle하지 않습니다
pub extern "C" fn _start() -> ! {
// 링커는 기본적으로 '_start' 라는 이름을 가진 함수를 실행 시작 지점으로 삼기에,
// 이 함수는 실행 시작 지점이 됩니다
loop {}
}
/// 패닉이 일어날 경우, 이 함수가 호출됩니다.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`:
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# `cargo build` 실행 시 이용되는 빌드 설정
[profile.dev]
panic = "abort" # 패닉 시 스택 되감기를 하지 않고 바로 프로그램 종료
# `cargo build --release` 실행 시 이용되는 빌드 설정
[profile.release]
panic = "abort" # 패닉 시 스택 되감기를 하지 않고 바로 프로그램 종료
```
이 실행 파일을 빌드하려면, `thumbv7em-none-eabihf`와 같은 bare metal 시스템 환경을 목표로 컴파일해야 합니다:
```
cargo build --target thumbv7em-none-eabihf
```
또다른 방법으로, 각 호스트 시스템마다 추가적인 링커 인자들을 전달해주어 호스트 시스템 환경을 목표로 컴파일할 수도 있습니다:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
주의할 것은 이것이 정말 최소한의 freestanding Rust 실행 파일이라는 것입니다. 실행 파일은 여러 가지 조건들을 가정하는데, 그 예로 실행파일 동작 시 `_start` 함수가 호출될 때 스택이 초기화되어 있을 것을 가정합니다. **이 freestanding 실행 파일을 이용해 실제로 유용한 작업을 처리하려면 아직 더 많은 코드 구현이 필요합니다**.
## 다음 단계는 무엇일까요?
[다음 포스트][next post]에서는 우리의 freestanding 실행 파일을 최소한의 기능을 갖춘 운영체제 커널로 만드는 과정을 단게별로 설명할 것입니다.
예시로 커스텀 시스템 환경을 설정하는 방법, 우리의 실행 파일을 부트로더와 합치는 방법, 그리고 화면에 메세지를 출력하는 방법 등에 대해 다루겠습니다.
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.md

View File

@@ -1,523 +0,0 @@
+++
title = "Независимый бинарный файл на Rust"
weight = 1
path = "ru/freestanding-rust-binary"
date = 2018-02-10
[extra]
chapter = "С нуля"
translators = ["MrZloHex"]
+++
Первый шаг в создании собственного ядра операционной системы &mdash; это создание исполняемого файла на Rust, который не будет подключать стандартную библиотеку. Именно это дает возможность запускать Rust код на [голом железе][bare metal] без слоя операционной системы.
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
Этот блог открыто разрабатывается на [GitHub]. Если у вас возникли какие-либо проблемы или вопросы, пожалуйста, создайте _issue_. Также вы можете оставлять комментарии [в конце страницы][at the bottom]. Полный исходный код для этого поста вы можете найти в репозитории в ветке [`post-01`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## Введение
Для того, чтобы написать ядро операционной системы, нужен код, который не зависит от операционной системы и ее возможностей. Это означает, что нельзя использовать потоки, файлы, [кучу][heap], сети, случайные числа, стандартный вывод или другие возможности, которые зависят от ОС или определённого железа.
[heap]: https://en.wikipedia.org/wiki/Heap_(data_structure)
Это значит, что нельзя использовать большую часть [стандартной библиотеки Rust][Rust Standard library], но остается множество других возможностей Rust, которые _можно использовать_. Например, [итераторы][iterators], [замыкания][closures], [сопоставление с образцом][pattern matching], [`Option`][option] и [`Result`][result], [форматирование строк][string formatting] и, конечно же, [систему владения][ownership system]. Эти функции дают возможность для написания ядра в очень выразительном и высоко-уровневом стиле, не беспокоясь о [неопределенном поведении][undefined behavior] или [сохранности памяти][memory safety].
[option]: https://doc.rust-lang.org/core/option/
[result]:https://doc.rust-lang.org/core/result/
[Rust standard library]: https://doc.rust-lang.org/std/
[iterators]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[string formatting]: https://doc.rust-lang.org/core/macro.write.html
[ownership system]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[undefined behavior]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[memory safety]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
Чтобы создать ядро ОС на Rust, нужно создать исполняемый файл, который мог бы запускаться без ОС.
Этот пост описывает необходимые шаги для создания независимого исполняемого файла на Rust и объясняет, почему эти шаги нужны. Если вам интересен только минимальный пример, можете сразу перейти к __[итогам](#summary)__.
## Отключение стандартной библиотеки
По умолчанию, все Rust-крейты подключают [стандартную библиотеку][standard library], которая зависит от возможностей операционной системы, таких как потоки, файлы, сети. Она также зависит от стандартной библиотки C `libc`, которая очень тесно взаимодействует с возможностями ОС. Так как мы хотим написать операционную систему, мы не можем использовать библиотеки, которые зависят от операционной системы. Поэтому необходимо отключить автоматические подключение стандартной библиотеки через [атрибут `no_std`][attribute].
[standard library]: https://doc.rust-lang.org/std/
[attribute]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
Мы начнем с создания нового проекта cargo. Самый простой способ сделать это &mdash; через командную строку:
```
cargo new blog_os --bin -- edition 2018
```
Я назвал этот проект `blog_os`, но вы можете назвать как вам угодно. Флаг `--bin` указывает на то, что мы хотим создать исполняемый файл (а не библиотеку), а флаг `--edition 2018` указывает, что мы хотим использовать [редакцию Rust 2018][edition] для нашего крейта. После выполнения команды cargo создаст каталог со следующей структурой:
[edition]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
`Cargo.toml` содержит данные и конфигурацию крейта, такие как _название, автор, [семантическую версию][semantic version]_ и _зависимости_ от других крейтов. Файл `src/main.rs` содержит корневой модуль нашего крейта и функцию `main`. Можно скомпилировать крейт с помощью `cargo build` и запустить скомпилированную программу `blog_os` в поддиректории `target/debug`.
[semantic version]: https://semver.org/
### Атрибут `no_std`
В данный момент наш крейт неявно подключает стандартную библиотеку. Это можно исправить путем добавления [атрибута `no_std`][attribute]:
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
Если сейчас попробовать скомпилировать программу (с помоцью команды `cargo build`), то появится следующая ошибка:
```
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
Эта ошибка объясняется тем, что [макрос `println`][macro] &mdash; часть стандартной библиотеки, которая была отключена. Поэтому у нас больше нет возможность выводить что-либо на экран. Это логично, так как `println` печатает через [стандартный вывод][standard output], который, в свою очередь, является специальным файловым дескриптором, предоставляемым операционной системой.
[macro]: https://doc.rust-lang.org/std/macro.println.html
[standard output]: https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29
Давайте уберем макрос `println` и попробуем скомпилировать еще раз:
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
Сейчас компилятор не может найти функцию `#[panic_handler]` и «элемент языка».
## Реализация _паники_
Атрибут `pаnic_handler` определяет функцию, которая должна вызываться, когда происходит [паника (panic)][panic]. Стандартная библиотека предоставляет собственную функцию обработчика паники, но после отключения стандартной библиотеки мы должны написать собственный обработчик:
[panic]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// in main.rs
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
Параметр [`PanicInfo`][PanicInfo] содержит название файла и строку, где произошла паника, и дополнительное сообщение с пояснением. Эта функция никогда не должна возвратиться, и такая функция называется [расходящейся][diverging functions] и она возращает [пустой тип]["never" type] `!`. Пока что мы ничего не можем сделать в этой функции, поэтому мы просто войдем в бесконечный цикл.
[PanicInfo]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[diverging functions]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
["never" type]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## Элемент языка `eh_personality`
Элементы языка &mdash; это специальные функции и типы, которые необходимы компилятору. Например, трейт [`Copy`] указывает компилятору, у каких типов есть [_семантика копирования_][`Copy`]. Если мы посмотрим на [реализацию][copy code] этого трейта, то увидим специальный атрибут `#[lang = "copy"]`, который говорит, что этот трейт является элементом языка.
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[copy code]: https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299
Несмотря на то, что можно предоставить свою реализацию элементов языка, это следует делать только в крайних случаях. Причина в том, что элементы языка являются крайне нестабильными деталями реализации, и компилятор даже не проверяет в них согласованность типов (поэтому он даже не проверяет, имеет ли функция правильные типы аргументов). К счастью, существует более стабильный способ исправить вышеупомянутую ошибку.
Элемент языка [`eh_personality`][language item] указывает на функцию, которая используется для реализации [раскрутки стека][stack unwinding]. По умолчанию, Rust использует раскрутку для запуска деструктуров для всех _живых_ переменных на стеке в случае [паники][panic]. Это гарантирует, что вся использованная память будет освобождена, и позволяет родительскому потоку перехватить панику и продолжить выполнение. Раскрутка &mdash; очень сложный процесс и требует некоторых специльных библиотек ОС (например, [libunwind] для Linux или [structured exception handling] для Windows), так что мы не должны использовать её для нашей операционной системы.
[language item]: https://github.com/rust-lang/rust/blob/edb368491551a77d77a48446d4ee88b35490c565/src/libpanic_unwind/gcc.rs#L11-L45
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
[libunwind]: https://www.nongnu.org/libunwind/
[structured exception handling]: https://docs.microsoft.com/ru-ru/windows/win32/debug/structured-exception-handling
### Отключение раскрутки
Существуют и другие случаи использования, для которых раскрутка нежелательна, поэтому Rust предоставляет опцию [прерывания выполнения при панике][abort on panic]. Это отключает генерацию информации о символах раскрутки и, таким образом, значительно уменьшает размер бинарного файла. Есть несколько мест, где мы можем отключить раскрутку. Самый простой способ &mdash; добавить следующие строки в наш `Cargo.toml`:
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
Это устанавливает стратегию паники на `abort` (прерывание) как для профиля `dev` (используемого для `cargo build`), так и для профиля `release` (используемого для `cargo build --release`). Теперь элемент языка `eh_personality` больше не должен требоваться.
[abort on panic]: https://github.com/rust-lang/rust/pull/32900
Теперь мы исправили обе вышеуказанные ошибки. Однако, если мы сейчас попытаемся скомпилировать программу, возникнет другая ошибка:
```
> cargo build
error: requires `start` lang_item
```
В нашей программе отсутствует элемент языка `start`, который определяет начальную точку входа программы.
## Аттрибут `start`
Можно подумать, что функция `main` &mdash; это первая функция, вызываемая при запуске программы. Однако в большинстве языков есть [среда выполнения][runtime system], которая отвечает за такие вещи, как сборка мусора (например, в Java) или программные потоки (например, goroutines в Go). Эта система выполнения должна быть вызвана до `main`, поскольку ей необходимо инициализировать себя.
[runtime system]: https://en.wikipedia.org/wiki/Runtime_system
В типичном исполнимом файле Rust, который использует стандартную библиотеку, выполнение начинается в runtime-библиотеке C под названием `crt0` ("C runtime zero"), которая создает окружение для C-приложения. Это включает создание стека и размещение аргументов в нужных регистрах. Затем C runtime вызывает [точку входа для Rust-приложения][rt::lang_start], которая обозначается элементом языка `start`. Rust имеет очень маленький runtime, который заботится о некоторых мелочах, таких как установка защиты от переполнения стека или вывод сообщения при панике. Затем рантайм вызывает функцию `main`.
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
Наш независимый исполняемый файл не имеет доступа к runtime Rust и `crt0`, поэтому нам нужно определить собственную точку входа. Реализация языкового элемента `start` не поможет, поскольку он все равно потребует `crt0`. Вместо этого нам нужно напрямую переопределить точку входа `crt0`.
### Переопределение точки входа
Чтобы сообщить компилятору Rust, что мы не хотим использовать стандартную цепочку точек входа, мы добавляем атрибут `#![no_main]`.
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
Можно заметить, что мы удалили функцию `main`. Причина в том, что `main` не имеет смысла без стандартного runtime, которая ее вызывает. Вместо этого мы переопределим точку входа операционной системы с помощью нашей собственной функции `_start`:
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
Используя атрибут `#[no_mangle]`, мы отключаем [искажение имен][name mangling], чтобы гарантировать, что компилятор Rust сгенерирует функцию с именем `_start`. Без этого атрибута компилятор генерировал бы какой-нибудь загадочный символ `_ZN3blog_os4_start7hb173fedf945531caE`, чтобы дать каждой функции уникальное имя. Атрибут необходим, потому что на следующем этапе нам нужно сообщить имя функции точки входа компоновщику.
Мы также должны пометить функцию как `extern "C"`, чтобы указать компилятору, что он должен использовать [соглашение о вызове C][C calling convention] для этой функции (вместо неопределенного соглашения о вызове Rust). Причина именования функции `_start` в том, что это имя точки входа по умолчанию для большинства систем.
[name mangling]: https://en.wikipedia.org/wiki/Name_mangling
[C calling convention]: https://en.wikipedia.org/wiki/Calling_convention
Возвращаемый `!` означает, что функция является расходящейся, т.е. не имеет права возвращаться. Это необходимо, поскольку точка входа не вызывается никакой функцией, а вызывается непосредственно операционной системой или загрузчиком. Поэтому вместо возврата точка входа должна, например, вызвать [системный вызов `exit`][`exit` system call] операционной системы. В нашем случае разумным действием может быть выключение машины, поскольку ничего не останется делать, если независимый исполнимый файл завершит исполнение. Пока что мы выполняем это требование путем бесконечного цикла.
[`exit` system call]: https://en.wikipedia.org/wiki/Exit_(system_call)
Если мы выполним `cargo build` сейчас, мы получим ошибку компоновщика (_linker_ error).
## Ошибки компоновщика
Компоновщик &mdash; это программа, которая объединяет сгенерированный код в исполняемый файл. Поскольку формат исполняемого файла отличается в Linux, Windows и macOS, в каждой системе есть свой компоновщик, и каждый покажет свою ошибку. Основная причина ошибок одна и та же: конфигурация компоновщика по умолчанию предполагает, что наша программа зависит от C runtime, а это не так.
Чтобы устранить ошибки, нам нужно сообщить компоновщику, что он не должен включать C runtime. Мы можем сделать это, передав компоновщику определенный набор аргументов или выполнив компиляцию для голого железа.
### Компиляция для голого железа
По умолчанию Rust пытается создать исполняемый файл, который может быть запущен в окружении вашей текущей системы. Например, если вы используете Windows на `x86_64`, Rust пытается создать исполняемый файл Windows `.exe`, который использует инструкции `x86_64`. Это окружение называется вашей "хост-системой".
Для описания различных окружений Rust использует строку [_target triple_]. Вы можете узнать тройку вашей хост-системы, выполнив команду `rustc --version --verbose`:
[_target triple_]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
Приведенный выше результат получен от системы `x86_64` Linux. Мы видим, что тройка `host` &mdash; это `x86_64-unknown-linux-gnu`, которая включает архитектуру процессора (`x86_64`), производителя (`unknown`), операционную систему (`linux`) и [ABI] (`gnu`).
[ABI]: https://en.wikipedia.org/wiki/Application_binary_interface
Компилируя для тройки нашего хоста, компилятор Rust и компоновщик предполагают наличие базовой операционной системы, такой как Linux или Windows, которая по умолчанию использует C runtime, что вызывает ошибки компоновщика. Поэтому, чтобы избежать ошибок компоновщика, мы можем настроить компиляцию для другого окружения без базовой операционной системы.
Примером такого "голого" окружения является тройка `thumbv7em-none-eabihf`, которая описывает [ARM] архитектуру. Детали не важны, важно лишь то, что тройка не имеет базовой операционной системы, на что указывает `none` в тройке. Чтобы иметь возможность компилировать для этой системы, нам нужно добавить ее в rustup:
[ARM]: https://en.wikipedia.org/wiki/ARM_architecture
```
rustup target add thumbv7em-none-eabihf
```
Это загружает копию стандартной библиотеки (и `core`) для системы. Теперь мы можем собрать наш независимый исполняемый файл для этой системы:
```
cargo build --target thumbv7em-none-eabihf
```
Передавая аргумент `--target`, мы [кросс-компилируем][cross compile] наш исполняемый файл для голого железа. Поскольку система, под которую мы компилируем, не имеет операционной системы, компоновщик не пытается компоновать C runtime, и наша компиляция проходит успешно без каких-либо ошибок компоновщика.
[cross compile]: https://en.wikipedia.org/wiki/Cross_compiler
Именно этот подход мы будем использовать для сборки ядра нашей ОС. Вместо `thumbv7em-none-eabihf` мы будем использовать [custom target], который описывает окружение для архитектуры `x86_64`. Подробности будут описаны в следующем посте.
[custom target]: https://doc.rust-lang.org/rustc/targets/custom.html
### Аргументы компоновщика
Вместо компиляции под голое железо, ошибки компоновщика можно исправить, передав ему определенный набор аргументов. Мы не будем использовать этот подход для нашего ядра, поэтому данный раздел является необязательным и приводится только для полноты картины. Щелкните на _"Аргументы компоновщика"_ ниже, чтобы показать необязательное содержание.
<details>
<summary>Аргументы компоновщика</summary>
В этом разделе мы рассмотрим ошибки компоновщика, возникающие в Linux, Windows и macOS, и объясним, как их решить, передав компоновщику дополнительные аргументы. Обратите внимание, что формат исполняемого файла и компоновщик отличаются в разных операционных системах, поэтому для каждой системы требуется свой набор аргументов.
#### Linux
На Linux возникает следующая ошибка компоновщика (сокращенно):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
Проблема заключается в том, что компоновщик по умолчанию включает процедуру запуска C runtime, которая также называется `_start`. Она требует некоторых символов стандартной библиотеки C `libc`, которые мы не включаем из-за атрибута `no_std`, поэтому компоновщик не может подключить эти библиотеки, поэтому появляются ошибки. Чтобы решить эту проблему, мы можем сказать компоновщику, что он не должен компоновать процедуру запуска C, передав флаг `-nostartfiles`.
Одним из способов передачи атрибутов компоновщика через cargo является команда `cargo rustc`. Команда ведет себя точно так же, как `cargo build`, но позволяет передавать опции `rustc`, базовому компилятору Rust. У `rustc` есть флаг `-C link-arg`, который передает аргумент компоновщику. В совокупности наша новая команда сборки выглядит следующим образом:
```
cargo rustc -- -C link-arg=-nostartfiles
```
Теперь наш крейт собирается как независимый исполняемый файл в Linux!
Нам не нужно было явно указывать имя нашей функции точки входа, поскольку компоновщик по умолчанию ищет функцию с именем `_start`.
#### Windows
В Windows возникает другая ошибка компоновщика (сокращенно):
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
Ошибка "точка входа должна быть определена" (_"entry point must be defined"_) означает, что компоновщик не может найти точку входа. В Windows имя точки входа по умолчанию [зависит от используемой подсистемы][windows-subsystems]. Для подсистемы `CONSOLE` компоновщик ищет функцию с именем `mainCRTStartup`, а для подсистемы `WINDOWS` - функцию с именем `WinMainCRTStartup`. Чтобы переопределить названия точки входа на `_start`, мы можем передать компоновщику аргумент `/ENTRY`:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
Из разного формата аргументов мы ясно видим, что компоновщик Windows - это совершенно другая программа, чем компоновщик Linux.
Теперь возникает другая ошибка компоновщика:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
Эта ошибка возникает из-за того, что исполняемые файлы Windows могут использовать различные [подсистемы][windows-subsystems]. Для обычных программ они определяются в зависимости от имени точки входа: если точка входа называется `main`, то используется подсистема `CONSOLE`, а если точка входа называется `WinMain`, то используется подсистема `WINDOWS`. Поскольку наша функция `_start` имеет другое имя, нам нужно явно указать подсистему:
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
Здесь мы используем подсистему `CONSOLE`, но подойдет и подсистема `WINDOWS`. Вместо того, чтобы передавать `-C link-arg` несколько раз, мы используем `-C link-args`, который принимает список аргументов, разделенных пробелами.
С помощью этой команды наш исполняемый файл должен успешно скомпилироваться под Windows.
#### macOS
На macOS возникает следующая ошибка компоновщика (сокращенно):
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
Это сообщение об ошибке говорит нам, что компоновщик не может найти функцию точки входа с именем по умолчанию `main` (по какой-то причине в macOS все функции имеют префикс `_`). Чтобы установить точку входа в нашу функцию `_start`, мы передаем аргумент компоновщика `-e`:
```
cargo rustc -- -C link-args="-e __start"
```
Флаг `-e` задает имя функции точки входа. Поскольку в macOS все функции имеют дополнительный префикс `_`, нам нужно установить точку входа на `__start` вместо `_start`.
Теперь возникает следующая ошибка компоновщика:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS [официально не поддерживает статически скомпонованные исполняемые файлы][static binary] и по умолчанию требует от программ компоновки библиотеки `libSystem`. Чтобы переопределить это поведение и скомпоновать статический исполняемый файл, передадим компоновщику флаг `-static`:
[static binary]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
Этого все равно недостаточно, так как возникает третья ошибка компоновщика:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
Эта ошибка возникает из-за того, что программы на macOS по умолчанию ссылаются на `crt0` ("C runtime zero"). Она похожа на ошибку под Linux и тоже может быть решена добавлением аргумента компоновщика `-nostartfiles`:
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
Теперь наша программа должна успешно скомпилироваться на macOS.
#### Объединение команд сборки
Сейчас у нас разные команды сборки в зависимости от платформы хоста, что не идеально. Чтобы избежать этого, мы можем создать файл с именем `.cargo/config.toml`, который будет содержать аргументы для конкретной платформы:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
Ключ `rustflags` содержит аргументы, которые автоматически добавляются к каждому вызову `rustc`. Более подробную информацию о файле `.cargo/config.toml` можно найти в [официальной документации](https://doc.rust-lang.org/cargo/reference/config.html).
Теперь наша программа должна собираться на всех трех платформах с помощью простой `cargo build`.
#### Должны ли вы это делать?
Хотя можно создать независимый исполняемый файл для Linux, Windows и macOS, это, вероятно, не очень хорошая идея. Причина в том, что наш исполняемый файл все еще ожидает различных вещей, например, инициализации стека при вызове функции `_start`. Без C runtime некоторые из этих требований могут быть не выполнены, что может привести к сбою нашей программы, например, из-за ошибки сегментации.
Если вы хотите создать минимальный исполняемый файл, запускаемый поверх существующей операционной системы, то включение `libc` и установка атрибута `#[start]`, как описано [здесь] (https://doc.rust-lang.org/1.16.0/book/no-stdlib.html), вероятно, будет идеей получше.
</details>
## Итоги {#summary}
Минимальный независимый исполняемый бинарный файл Rust выглядит примерно так:
`src/main.rs`:
```rust
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`:
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic
# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic
```
Чтобы собрать этот исполняемый файл, его надо скомпилировать для голого железа, например, `thumbv7em-none-eabihf`:
```
cargo build --target thumbv7em-none-eabihf
```
В качестве альтернативы, мы можем скомпилировать его для хост-системы, передав дополнительные аргументы компоновщика:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
Обратите внимание, что это лишь минимальный пример независимого бинарного файла Rust. Этот бинарник ожидает различных вещей, например, инициализацию стека при вызове функции `_start`. **Поэтому для любого реального использования такого бинарного файла потребуется совершить еще больше действий**.
## Что дальше?
В [следующем посте][next post] описаны шаги, необходимые для превращения нашего независимого бинарного файла в минимальное ядро операционной системы. Сюда входит создание custom target, объединение нашего исполняемого файла с загрузчиком и изучение, как вывести что-то на экран.
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.ru.md

View File

@@ -1,479 +0,0 @@
+++
title = "独立式可执行程序"
weight = 1
path = "zh-CN/freestanding-rust-binary"
date = 2018-02-10
[extra]
# Please update this when updating the translation
translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"
# GitHub usernames of the people that translated this post
translators = ["luojia65", "Rustin-Liu", "TheBegining", "liuyuran"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["JiangengDong"]
+++
创建一个不链接标准库的 Rust 可执行文件,将是我们迈出的第一步。无需底层操作系统的支撑,这样才能在**裸机**[bare metal])上运行 Rust 代码。
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
此博客在 [GitHub] 上公开开发. 如果您有任何问题或疑问,请在此处打开一个 issue。 您也可以在[底部][at the bottom]发表评论. 这篇文章的完整源代码可以在 [`post-01`] [post branch] 分支中找到。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## 简介
要编写一个操作系统内核,我们需要编写不依赖任何操作系统特性的代码。这意味着我们不能使用线程、文件、堆内存、网络、随机数、标准输出,或其它任何需要操作系统抽象和特定硬件的特性;因为我们正在编写自己的操作系统和硬件驱动。
实现这一点,意味着我们不能使用 [Rust标准库](https://doc.rust-lang.org/std/)的大部分;但还有很多 Rust 特性是我们依然可以使用的。比如说,我们可以使用[迭代器](https://doc.rust-lang.org/book/ch13-02-iterators.html)、[闭包](https://doc.rust-lang.org/book/ch13-01-closures.html)、[模式匹配](https://doc.rust-lang.org/book/ch06-00-enums.html)、[Option](https://doc.rust-lang.org/core/option/)、[Result](https://doc.rust-lang.org/core/result/index.html)、[字符串格式化](https://doc.rust-lang.org/core/macro.write.html),当然还有[所有权系统](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html)。这些功能让我们能够编写表达性强、高层抽象的操作系统,而无需关心[未定义行为](https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs)和[内存安全](https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention)。
为了用 Rust 编写一个操作系统内核,我们需要创建一个独立于操作系统的可执行程序。这样的可执行程序常被称作**独立式可执行程序**freestanding executable或**裸机程序**(bare-metal executable)。
在这篇文章里,我们将逐步地创建一个独立式可执行程序,并且详细解释为什么每个步骤都是必须的。如果读者只对最终的代码感兴趣,可以跳转到本篇文章的小结部分。
## 禁用标准库
在默认情况下,所有的 Rust **包**crate都会链接**标准库**[standard library](https://doc.rust-lang.org/std/)),而标准库依赖于操作系统功能,如线程、文件系统、网络。标准库还与 **Rust 的 C 语言标准库实现库**libc相关联它也是和操作系统紧密交互的。既然我们的计划是编写自己的操作系统我们就需要不使用任何与操作系统相关的库——因此我们必须禁用**标准库自动引用**automatic inclusion。使用 [no_std 属性](https://doc.rust-lang.org/book/first-edition/using-rust-without-the-standard-library.html)可以实现这一点。
我们可以从创建一个新的 cargo 项目开始。最简单的办法是使用下面的命令:
```bash
cargo new blog_os --bin --edition 2018
```
在这里我把项目命名为 `blog_os`当然读者也可以选择自己的项目名称。默认情况下即使不显式指定cargo 也会为我们添加`--bin` 选项,说明我们将要创建一个可执行文件(而不是一个库); 另外 `--edition 2018` 参数指明了项目的包要使用 Rust 的 **2018 版次**[2018 edition]但在默认情况下该参数会指向本地安装的最新版本。当我们成功执行这行指令后cargo 为我们创建的目录结构如下:
[2018 edition]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
在这里,`Cargo.toml` 文件包含了包的**配置**configuration比如包的名称、作者、[semver版本](https://semver.org/) 和项目依赖项;`src/main.rs` 文件包含包的**根模块**root module和 main 函数。我们可以使用 `cargo build` 来编译这个包,然后在 `target/debug` 文件夹内找到编译好的 `blog_os` 二进制文件。
### no_std 属性
现在我们的包依然隐式地与标准库链接。为了禁用这种链接,我们可以尝试添加 [no_std 属性](https://doc.rust-lang.org/book/first-edition/using-rust-without-the-standard-library.html)
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
看起来很顺利。当我们使用 `cargo build` 来编译的时候,却出现了下面的错误:
```rust
error: cannot find macro `println!` in this scope
--> src\main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
出现这个错误的原因是:[println! 宏](https://doc.rust-lang.org/std/macro.println.html)是标准库的一部分,而我们的项目不再依赖于标准库。我们选择不再打印字符串。这也很好理解,因为 `println!` 将会向**标准输出**[standard output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29))打印字符,它依赖于特殊的文件描述符,而这是由操作系统提供的特性。
所以我们可以移除这行代码,使用一个空的 main 函数再次尝试编译:
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
现在我们发现,编译器缺少一个 `#[panic_handler]` 函数和一个**语言项**language item
## 实现 panic 处理函数
`panic_handler` 属性定义了一个函数,它会在一个 panic 发生时被调用。标准库中提供了自己的 panic 处理函数,但在 `no_std` 环境中,我们需要定义一个自己的 panic 处理函数:
```rust
// in main.rs
use core::panic::PanicInfo;
/// 这个函数将在 panic 时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
类型为 [PanicInfo](https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html) 的参数包含了 panic 发生的文件名、代码行数和可选的错误信息。这个函数从不返回,所以他被标记为**发散函数**[diverging function])。发散函数的返回类型称作 **Never 类型**["never" type](https://doc.rust-lang.org/nightly/std/primitive.never.html)),记为`!`。对这个函数,我们目前能做的很少,所以我们只需编写一个无限循环 `loop {}`
[diverging function]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
## eh_personality 语言项
语言项是一些编译器需求的特殊函数或类型。举例来说Rust 的 [Copy](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html) trait 是一个这样的语言项,告诉编译器哪些类型需要遵循**复制语义**[copy semantics](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html))——当我们查找 `Copy` trait 的[实现](https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299)时,我们会发现,一个特殊的 `#[lang = "copy"]` 属性将它定义为了一个语言项,达到与编译器联系的目的。
我们可以自己实现语言项,但这是下下策:目前来看,语言项是高度不稳定的语言细节实现,它们不会经过编译期类型检查(所以编译器甚至不确保它们的参数类型是否正确)。幸运的是,我们有更稳定的方式,来修复上面的语言项错误。
`eh_personality` 语言项标记的函数,将被用于实现**栈展开**[stack unwinding](https://www.bogotobogo.com/cplusplus/stackunwinding.php))。在使用标准库的情况下,当 panic 发生时Rust 将使用栈展开,来运行在栈上所有活跃的变量的**析构函数**destructor——这确保了所有使用的内存都被释放允许调用程序的**父进程**parent thread捕获 panic处理并继续运行。但是栈展开是一个复杂的过程如 Linux 的 [libunwind](https://www.nongnu.org/libunwind/) 或 Windows 的**结构化异常处理**[structured exception handling, SEH](https://docs.microsoft.com/en-us/windows/win32/debug/structured-exception-handling)),通常需要依赖于操作系统的库;所以我们不在自己编写的操作系统中使用它。
### 禁用栈展开
在其它一些情况下栈展开并不是迫切需求的功能因此Rust 提供了**在 panic 时中止**[abort on panic](https://github.com/rust-lang/rust/pull/32900))的选项。这个选项能禁用栈展开相关的标志信息生成,也因此能缩小生成的二进制程序的长度。有许多方式能打开这个选项,最简单的方式是把下面的几行设置代码加入我们的 `Cargo.toml`
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
这些选项能将 **dev 配置**dev profile**release 配置**release profile的 panic 策略设为 `abort``dev` 配置适用于 `cargo build`,而 `release` 配置适用于 `cargo build --release`。现在编译器应该不再要求我们提供 `eh_personality` 语言项实现。
现在我们已经修复了出现的两个错误,可以开始编译了。然而,尝试编译运行后,一个新的错误出现了:
```bash
> cargo build
error: requires `start` lang_item
```
## start 语言项
这里,我们的程序遗失了 `start` 语言项,它将定义一个程序的**入口点**entry point
我们通常会认为,当运行一个程序时,首先被调用的是 `main` 函数。但是,大多数语言都拥有一个**运行时系统**[runtime system](https://en.wikipedia.org/wiki/Runtime_system)),它通常为**垃圾回收**garbage collection或**绿色线程**software threads或 green threads服务如 Java 的 GC 或 Go 语言的协程goroutine这个运行时系统需要在 main 函数前启动,因为它需要让程序初始化。
在一个典型的使用标准库的 Rust 程序中,程序运行是从一个名为 `crt0` 的运行时库开始的。`crt0` 意为 C runtime zero它能建立一个适合运行 C 语言程序的环境,这包含了栈的创建和可执行程序参数的传入。在这之后,这个运行时库会调用 [Rust 的运行时入口点](https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73),这个入口点被称作 **start语言项**"start" language item。Rust 只拥有一个极小的运行时,它被设计为拥有较少的功能,如爆栈检测和打印**栈轨迹**stack trace。这之后这个运行时将会调用 main 函数。
我们的独立式可执行程序并不能访问 Rust 运行时或 `crt0` 库,所以我们需要定义自己的入口点。只实现一个 `start` 语言项并不能帮助我们,因为这之后程序依然要求 `crt0` 库。所以,我们要做的是,直接重写整个 `crt0` 库和它定义的入口点。
### 重写入口点
要告诉 Rust 编译器我们不使用预定义的入口点,我们可以添加 `#![no_main]` 属性。
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// 这个函数将在 panic 时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
读者也许会注意到,我们移除了 `main` 函数。原因很显然,既然没有底层运行时调用它,`main` 函数也失去了存在的必要性。为了重写操作系统的入口点,我们转而编写一个 `_start` 函数:
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
我们使用 `no_mangle` 标记这个函数,来对它禁用**名称重整**[name mangling](https://en.wikipedia.org/wiki/Name_mangling))——这确保 Rust 编译器输出一个名为 `_start` 的函数;否则,编译器可能最终生成名为 `_ZN3blog_os4_start7hb173fedf945531caE` 的函数,无法让链接器正确辨别。
我们还将函数标记为 `extern "C"`,告诉编译器这个函数应当使用 [C 语言的调用约定](https://en.wikipedia.org/wiki/Calling_convention),而不是 Rust 语言的调用约定。函数名为 `_start` ,是因为大多数系统默认使用这个名字作为入口点名称。
与前文的 `panic` 函数类似,这个函数的返回值类型为`!`——它定义了一个发散函数,或者说一个不允许返回的函数。这一点很重要,因为这个入口点不会被任何函数调用,但将直接被操作系统或**引导程序**bootloader调用。所以作为函数返回的替代这个入口点应该去调用比如操作系统提供的 **exit 系统调用**["exit" system call](https://en.wikipedia.org/wiki/Exit_(system_call)))函数。在我们编写操作系统的情况下,关机应该是一个合适的选择,因为**当一个独立式可执行程序返回时,不会留下任何需要做的事情**there is nothing to do if a freestanding binary returns。现在来看我们可以添加一个无限循环来满足对返回值类型的需求。
如果我们现在编译这段程序,会出来一大段不太好看的**链接器错误**linker error
## 链接器错误
**链接器**linker是一个程序它将生成的目标文件组合为一个可执行文件。不同的操作系统如 Windows、macOS、Linux规定了不同的可执行文件格式因此也各有自己的链接器抛出不同的错误但这些错误的根本原因还是相同的链接器的默认配置假定程序依赖于C语言的运行时环境但我们的程序并不依赖于它。
为了解决这个错误我们需要告诉链接器它不应该包含includeC 语言运行环境。我们可以选择提供特定的**链接器参数**linker argument也可以选择编译为**裸机目标**bare metal target
### 编译为裸机目标
在默认情况下Rust 尝试适配当前的系统环境,编译可执行程序。举个例子,如果你使用 `x86_64` 平台的 Windows 系统Rust 将尝试编译一个扩展名为 `.exe` 的 Windows 可执行程序,并使用 `x86_64` 指令集。这个环境又被称作为你的**宿主系统**"host" system
为了描述不同的环境Rust 使用一个称为**目标三元组**target triple的字符串。要查看当前系统的目标三元组我们可以运行 `rustc --version --verbose`
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
上面这段输出来自一个 `x86_64` 平台下的 Linux 系统。我们能看到,`host` 字段的值为三元组 `x86_64-unknown-linux-gnu`,它包含了 CPU 架构 `x86_64` 、供应商 `unknown` 、操作系统 `linux` 和[二进制接口](https://en.wikipedia.org/wiki/Application_binary_interface) `gnu`
Rust 编译器尝试为当前系统的三元组编译,并假定底层有一个类似于 Windows 或 Linux 的操作系统提供C语言运行环境——然而这将导致链接器错误。所以为了避免这个错误我们可以另选一个底层没有操作系统的运行环境。
这样的运行环境被称作裸机环境,例如目标三元组 `thumbv7em-none-eabihf` 描述了一个 ARM **嵌入式系统**[embedded system](https://en.wikipedia.org/wiki/Embedded_system))。我们暂时不需要了解它的细节,只需要知道这个环境底层没有操作系统——这是由三元组中的 `none` 描述的。要为这个目标编译,我们需要使用 rustup 添加它:
```
rustup target add thumbv7em-none-eabihf
```
这行命令将为目标下载一个标准库和 core 库。这之后,我们就能为这个目标构建独立式可执行程序了:
```
cargo build --target thumbv7em-none-eabihf
```
我们传递了 `--target` 参数,来为裸机目标系统**交叉编译**[cross compile](https://en.wikipedia.org/wiki/Cross_compiler))我们的程序。我们的目标并不包括操作系统,所以链接器不会试着链接 C 语言运行环境,因此构建过程会成功完成,不会产生链接器错误。
我们将使用这个方法编写自己的操作系统内核。我们不会编译到 `thumbv7em-none-eabihf`,而是使用描述 `x86_64` 环境的**自定义目标**[custom target](https://doc.rust-lang.org/rustc/targets/custom.html))。在下一篇文章中,我们将详细描述一些相关的细节。
### 链接器参数
我们也可以选择不编译到裸机系统,因为传递特定的参数也能解决链接器错误问题。虽然我们不会在后面使用到这个方法,为了教程的完整性,我们也撰写了专门的短文章,来提供这个途径的解决方案。
如有需要,请点击下方的 _"链接器参数"_ 按钮来展开可选内容。
<details>
<summary>链接器参数</summary>
在本章节中我们讨论了Linux、Windows和macOS中遇到的链接错误并阐述如何通过传递额外参数来解决这些错误。注意由于不同操作系统的可执行文件内在格式不同所以对于不同操作系统而言所适用的额外参数也有所不同。
#### Linux
在Linux下会触发以下链接错误简化版
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
这里的问题在于链接器默认包含了C启动例程即构建名为 `_start` 的入口函数的地方。但其依赖一些C标准库 `libc` 中的符号,而我们已经使用 `no_std` 开关排除掉了这些符号,所以链接器报告了这些错误。要解决这个问题,我们需要通过 `-nostartfiles` 参数来告诉链接器不要使用C启动例程功能。
通过 `cargo rustc` 可以传递链接器参数,该命令和 `cargo build` 的效果完全一致但是可以将参数传递给rust的底层编译器 `rustc``rustc` 支持 `-C link-arg` 参数,此参数可以传递参数给配套的链接器。那么以此推断,我们的编译语句可以这样写:
```
cargo rustc -- -C link-arg=-nostartfiles
```
现在我们编译出的程序就可以在Linux上独立运行了。
我们并不需要显式指定入口函数名,链接器默认会查找 `_start` 函数作为入口点。
#### Windows
在Windows下会触发以下链接错误简化版
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
错误信息 “entry point must be defined” 意味着链接器没有找到程序入口点。在Windows环境下默认入口点[取决于使用的子系统][windows-subsystems]。对于 `CONSOLE` 子系统,链接器会寻找 `mainCRTStartup` 函数作为入口,而对于 `WINDOWS` 子系统,入口函数名叫做 `WinMainCRTStartup`。要复写掉入口函数名的默认设定,使其使用我们已经定义的 `_start` 函数,可以将 `/ENTRY` 参数传递给链接器:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
显而易见从链接参数上看Windows平台使用的链接器和Linux平台是完全不同的。
此时可能你还会遇到这个链接错误:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
该错误的原因是Windows平台下的可执行文件可以使用不同的[子系统][windows-subsystems]。一般而言,操作系统会如此判断:如果入口函数名叫 `main` ,则会使用 `CONSOLE` 子系统;若名叫 `WinMain` ,则会使用 `WINDOWS` 子系统。然而此时我们使用的入口函数名叫 `_start` ,两者都不是,此时就需要显式指定子系统:
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
这里我们使用了 `CONSOLE` 子系统,如果使用 `WINDOWS` 子系统其实也可以。但是多次使用 `-C link-arg` 参数大可不必,我们可以如上面一样,将一个引号包裹起来的以空格分隔的列表传递给 `-C link-arg` 参数。
现在我们编译出的程序就可以在Windows平台成功运行了。
#### macOS
在macOS下会触发以下链接错误简化版
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
该错误告诉我们链接器找不到入口函数 `main` 由于某些原因macOS平台下所有函数都会具有 `_` 前缀)。要重设入口函数名,我们可以传入链接器参数 `-e`
```
cargo rustc -- -C link-args="-e __start"
```
`-e` 参数可用于重设入口函数名。由于在macOS平台下所有函数都具有 `_` 前缀,所以需要传入 `__start` ,而不是 `_start`
接下来,会出现一个新的链接错误:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS [并未官方支持静态链接][does not officially support statically linked binaries] ,并且在默认情况下程序会链接 `libSystem` 库。要复写这个设定并进行静态链接,我们可以传入链接器参数 `-static`
[does not officially support statically linked binaries]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
然而问题并没有解决,链接器再次抛出了一个错误:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
该错误的原因是macOS平台下的程序会默认链接 `crt0` 即“C runtime zero”。 这个错误实际上和Linux平台上的错误类似可以添加链接器参数 `-nostartfiles` 解决:
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
现在我们的程序可以在macOS下编译成功了。
#### 统一编译命令
经过上面的章节,我们知道了在各个平台使用的编译命令是不同的,这十分不优雅。要解决这个问题,我们可以创建一个 `.cargo/config.toml` 文件,分别配置不同平台下所使用的参数:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
对应的 `rustflags` 配置项的值可以自动被填充到 `rustc` 的运行参数中。要寻找 `.cargo/config.toml` 更多的用法,可以看一下 [官方文档](https://doc.rust-lang.org/cargo/reference/config.html)。
现在只需要运行 `cargo build` 即可在全部三个平台编译我们的程序了。
#### 我们真的需要做这些?
尽管我们可以在Linux、Windows和macOS编译出可执行程序但这可能并非是个好主意。
因为我们的程序少了不少本该存在的东西,比如 `_start` 执行时的栈初始化。
失去了C运行时部分基于它的依赖项很可能无法正确执行这会造成程序出现各式各样的异常比如segmentation fault段错误
如果你希望创建一个基于已存在的操作系统的最小类库,建议引用 `libc` ,阅读 [这里](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html) 并恰当设定 `#[start]` 比较好。
</details>
## 小结
一个用 Rust 编写的最小化的独立式可执行程序应该长这样:
`src/main.rs`
```rust
#![no_std] // 不链接 Rust 标准库
#![no_main] // 禁用所有 Rust 层级的入口点
use core::panic::PanicInfo;
#[no_mangle] // 不重整函数名
pub extern "C" fn _start() -> ! {
// 因为链接器会寻找一个名为 `_start` 的函数,所以这个函数就是入口点
// 默认命名为 `_start`
loop {}
}
/// 这个函数将在 panic 时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# 使用 `cargo build` 编译时需要的配置
[profile.dev]
panic = "abort" # 禁用panic时栈展开
# 使用 `cargo build --release` 编译时需要的配置
[profile.release]
panic = "abort" # 禁用 panic 时栈展开
```
选用任意一个裸机目标来编译。比如对 `thumbv7em-none-eabihf`,我们使用以下命令:
```bash
cargo build --target thumbv7em-none-eabihf
```
另外,我们也可以选择以本地操作系统为目标进行编译:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
要注意的是,现在我们的代码只是一个 Rust 编写的独立式可执行程序的一个例子。运行这个二进制程序还需要很多准备,比如在 `_start` 函数之前需要一个已经预加载完毕的栈。所以为了真正运行这样的程序,**我们还有很多事情需要做**。
## 下篇预览
下一篇文章要做的事情基于我们这篇文章的成果,它将详细讲述编写一个最小的操作系统内核需要的步骤:如何配置特定的编译目标,如何将可执行程序与引导程序拼接,以及如何把一些特定的字符串打印到屏幕上。
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.md

View File

@@ -1,520 +0,0 @@
+++
title = "獨立的 Rust 二進制檔"
weight = 1
path = "zh-TW/freestanding-rust-binary"
date = 2018-02-10
[extra]
# Please update this when updating the translation
translation_based_on_commit = "24d04e0e39a3395ecdce795bab0963cb6afe1bfd"
# GitHub usernames of the people that translated this post
translators = ["wusyong"]
+++
建立我們自己的作業系統核心的第一步是建立一個不連結標準函式庫的 Rust 執行檔,這使得無需基礎作業系統即可在[裸機][bare metal]上執行 Rust 程式碼。
[bare metal]: https://en.wikipedia.org/wiki/Bare_machine
<!-- more -->
此網誌在 [GitHub] 上公開開發,如果您有任何問題或疑問,請在那開一個 issue您也可以在[下面][at the bottom]發表評論,這篇文章的完整開源程式碼可以在 [`post-01`][post branch] 分支中找到。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-01
<!-- toc -->
## 介紹
要編寫作業系統核心,我們需要不依賴於任何作業系統功能的程式碼。這代表我們不能使用執行緒、檔案系統、堆記憶體、網路、隨機數、標準輸出或任何其他需要作業系統抽象或特定硬體的功能。這也是理所當然的,因為我們正在嘗試寫出自己的 OS 和我們的驅動程式。
這意味著我們不能使用大多數的 [Rust 標準函式庫][Rust standard library],但是我們還是可以使用 _很多_ Rust 的功能。比如說我們可以使用[疊代器][iterators]、[閉包][closures]、[模式配對][pattern matching]、[option] 和 [result]、[字串格式化][string formatting],當然還有[所有權系統][ownership system]。這些功能讓我們能夠以非常有表達力且高階的方式編寫核心,而無需擔心[未定義行為][undefined behavior]或[記憶體安全][memory safety]。
[option]: https://doc.rust-lang.org/core/option/
[result]:https://doc.rust-lang.org/core/result/
[Rust standard library]: https://doc.rust-lang.org/std/
[iterators]: https://doc.rust-lang.org/book/ch13-02-iterators.html
[closures]: https://doc.rust-lang.org/book/ch13-01-closures.html
[pattern matching]: https://doc.rust-lang.org/book/ch06-00-enums.html
[string formatting]: https://doc.rust-lang.org/core/macro.write.html
[ownership system]: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
[undefined behavior]: https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs
[memory safety]: https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention
為了在 Rust 中建立 OS 核心我們需要建立一個無須底層作業系統即可運行的執行檔這類的執行檔通常稱為「獨立式freestanding」或「裸機bare-metal」的執行檔。
這篇文章描述了建立一個獨立的 Rust 執行檔的必要步驟,並解釋為什麼需要這些步驟。如果您只對簡單的範例感興趣,可以直接跳到 **[總結](#summary)**。
## 停用標準函式庫
Rust 所有的 crate 在預設情況下都會連結[標準函式庫][standard library],而標準函式庫會依賴作業系統的功能,像式執行緒、檔案系統或是網路。它也會依賴 C 語言的標準函式庫 `libc`,因為其與作業系統緊密相關。既然我們的計劃是編寫自己的作業系統,我們就得用到 [`no_std` 屬性][`no_std` attribute]來停止標準函式庫的自動引用automatic inclusion
[standard library]: https://doc.rust-lang.org/std/
[`no_std` attribute]: https://doc.rust-lang.org/1.30.0/book/first-edition/using-rust-without-the-standard-library.html
我們先從建立一個新的 cargo 專案開始,最簡單的辦法是輸入下面的命令:
```
cargo new blog_os --bin --edition 2018
```
我將專案命名為 `blog_os`,當然讀者也可以自己的名稱。`--bin` 選項說明我們將要建立一個執行檔(而不是一個函式庫),`--edition 2018` 選項指明我們的 crate 想使用 Rust [2018 版本][2018 edition]。當我們執行這行指令的時候cargo 會為我們建立以下目錄結構:
[2018 edition]: https://doc.rust-lang.org/nightly/edition-guide/rust-2018/index.html
```
blog_os
├── Cargo.toml
└── src
└── main.rs
```
`Cargo.toml` 包含 crate 的設置,像是 crate 的名稱、作者、[語意化版本][semantic version]以及依賴套件。`src/main.rs` 檔案則包含 crate 的根模組root module以及我們的 `main` 函式。您可以用 `cargo build` 編譯您的 crate 然後在 `target/debug` 目錄下運行編譯過後的 `blog_os` 執行檔。
[semantic version]: https://semver.org/lang/zh-TW/
### no_std 屬性
現在我們的 crate 背後依然有和標準函式庫連結。讓我們加上 [`no_std` 屬性][`no_std` attribute] 來停用:
```rust
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
```
當我們嘗試用 `cargo build` 編譯時會出現以下錯誤訊息:
```
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
| ^^^^^^^
```
出現這個錯誤的原因是因為 [`println` 巨集macro][`println` macro]是標準函式庫的一部份,而我們不再包含它,所以我們無法再輸出東西來。這也是理所當然因為 `println` 會寫到[標準輸出][standard output],而這是一個由作業系統提供的特殊檔案描述符。
[`println` macro]: https://doc.rust-lang.org/std/macro.println.html
[standard output]: https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29
所以讓我們移除這行程式碼,然後用空的 main 函式再試一次:
```rust
// main.rs
#![no_std]
fn main() {}
```
```
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
```
現在編譯氣告訴我們缺少 `#[panic_handler]` 函式以及 _language item_
## 實作 panic 處理函式
`panic_handler` 屬性定義了當 [panic] 發生時編譯器需要呼叫的函式。在標準函式庫中有自己的 panic 處理函式,但在 `no_std` 的環境中我們得定義我們自己的:
[panic]: https://doc.rust-lang.org/stable/book/ch09-01-unrecoverable-errors-with-panic.html
```rust
// main.rs
use core::panic::PanicInfo;
/// 此函式會在 panic 時呼叫。
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
[`PanicInfo` parameter][PanicInfo] 包含 panic 發生時的檔案、行數以及可選的錯誤訊息。這個函式不會返回,所以它被標記為[發散函式][diverging function],只會返回[“never” 型態][“never” type] `!`。現在我們什麼事可以做,所以我們只需寫一個無限迴圈。
[PanicInfo]: https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html
[diverging function]: https://doc.rust-lang.org/1.30.0/book/first-edition/functions.html#diverging-functions
[“never” type]: https://doc.rust-lang.org/nightly/std/primitive.never.html
## eh_personality Language Item
Language item 是一些編譯器需求的特殊函式或類型。舉例來說Rust 的 [`Copy`] trait 就是一個 language item告訴編譯器哪些類型擁有[_複製的語意_][`Copy`]。當我們搜尋 `Copy` trait 的[實作][copy code]時,我們會發現一個特殊的 `#[lang = "copy"]` 屬性將它定義為一個 language item。
我們可以自己實現 language item但這只應是最後的手段。因為 language item 屬於非常不穩定的實作細節,而且不會做類型檢查(所以編譯器甚至不會確保它們的參數類型是否正確)。幸運的是,我們有更穩定的方式來修復上面關於 language item 的錯誤。
`eh_personality` language item 標記的函式將被用於實作[堆疊回溯][stack unwinding]。在預設情況下當 panic 發生時Rust 會使用堆疊回溯來執行所有存在堆疊上變數的解構子destructor。這確保所有使用的記憶體都被釋放並讓 parent thread 獲取 panic 資訊並繼續運行。但是堆疊回溯是一個複雜的過程,通常會需要一些 OS 的函式庫如 Linux 的 [libunwind] 或 Windows 的 [structured exception handling]。所以我們並不希望在我們的作業系統中使用它。
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
[libunwind]: https://www.nongnu.org/libunwind/
[structured exception handling]: https://docs.microsoft.com/en-us/windows/win32/debug/structured-exception-handling
### 停用回溯
在某些狀況下回溯可能並不是我們要的功能,因此 Rust 提供了[在 panic 時中止][abort on panic]的選項。這個選項能停用回溯標誌訊息的產生,也因此能縮小生成的二進制檔案大小。我們能用許多方式開啟這個選項,而最簡單的方式就是把以下幾行設置加入我們的 `Cargo.toml`
```toml
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
```
這些選項能將 `dev` 設置(用於 `cargo build`)和 `release` 設置(用於 `cargo build --release`)的 panic 策略設為 `abort`。現在編譯器不會再要求我們提供 `eh_personality` language item。
[abort on panic]: https://github.com/rust-lang/rust/pull/32900
現在我們已經修復了上面的錯誤,但是如果我們嘗試編譯的話,又會出現一個新的錯誤:
```
> cargo build
error: requires `start` lang_item
```
我們的程式缺少 `start` 這個用來定義入口點entry point的 language item。
## `start` 屬性
我們通常會認為執行一個程式時,首先被呼叫的是 `main` 函式。但是大多數語言都擁有一個[執行時系統][runtime system]它通常負責垃圾回收garbage collection像是 Java 或軟體執行緒software threads像是 Go 的 goroutines。這個執行時系統需要在 main 函式前啟動,因為它需要讓先進行初始化。
[runtime system]: https://en.wikipedia.org/wiki/Runtime_system
在一個典型使用標準函式庫的 Rust 程式中,程式運行是從一個名為 `crt0`“C runtime zero”的執行時函式庫開始的它會設置 C 程式的執行環境。這包含建立堆疊和可執行程式參數的傳入。在這之後,這個執行時函式庫會呼叫 [Rust 的執行時入口點][rt::lang_start],而此處就是由 `start` language item 標記。 Rust 只有一個非常小的執行時系統,負責處理一些小事情,像是堆疊溢位或是印出 panic 時回溯的訊息。再來執行時系統最終才會呼叫 main 函式。
[rt::lang_start]: https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73
我們的獨立式可執行檔並沒有辦法存取 Rust 執行時系統或 `crt0`,所以我們需要定義自己的入口點。實作 `start` language item 並沒有用,因為這樣還是會需要 `crt0`。所以我們要做的是直接覆寫 `crt0` 的入口點。
### 重寫入口點
為了告訴 Rust 編譯器我們不要使用一般的入口點呼叫順序,我們先加上 `#![no_main]` 屬性。
```rust
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// 此函式會在 panic 時呼叫。
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
您可能會注意到我們移除了 `main` 函式,原因是因為既然沒有了底層的執行時系統呼叫,那麼 `main` 也沒必要存在。我們要重寫作業系統的入口點,定義為 `_start` 函式:
```rust
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
```
我們使用 `no_mangle` 屬性來停用[名字修飾][name mangling],確保 Rust 編譯器輸出的函式名稱會是 `_start`。沒有這個屬性的話,編譯器會產生符號像是 `_ZN3blog_os4_start7hb173fedf945531caE` 來讓每個函式的名稱都是獨一無二的。我們會需要這項屬性的原因是因為我們接下來希望連結器能夠呼叫入口點函式的名稱。
我們還將函式標記為 `extern "C"` 來告訴編譯器這個函式應當使用 [C 的調用約定][C calling convention],而不是 Rust 的調用約定。而函式名稱選用 `_start` 的原因是因為這是大多數系統的預設入口點名稱。
[name mangling]: https://en.wikipedia.org/wiki/Name_mangling
[C calling convention]: https://en.wikipedia.org/wiki/Calling_convention
`!` 返回型態代表這個函式是發散函式它不允許返回。這是必要的因為入口點不會被任何函式呼叫只會直接由作業系統或啟動程式bootloader執行。所以取代返回值的是入口點需要執行作業系統的 [`exit` 系統呼叫][`exit` system call]。在我們的例子中,關閉機器似乎是個理想的動作,因為獨立的二進制檔案返回後也沒什麼事可做。現在我們先寫一個無窮迴圈來滿足需求。
[`exit` system call]: https://en.wikipedia.org/wiki/Exit_(system_call)
當我們現在運行 `cargo build` 的話會看到很醜的 _連結器_ 錯誤。
## 連結器錯誤
連結器是用來將產生的程式碼結合起來成為執行檔的程式。因為 Linux、Windows 和 macOS 之間的執行檔格式都不同,每個系統都會有自己的連結器錯誤。不過造成錯誤的原因通常都差不多:連結器預設的設定會認為我們的程式依賴於 C 的執行時系統,但我們並沒有。
為了解決這個錯誤,我們需要告訴連結器它不需要包含 C 的執行時系統。我們可以選擇提供特定的連結器參數設定,或是選擇編譯為裸機目標。
### 編譯為裸機目標
Rust 在預設情況下會嘗試編譯出符合你目前系統環境的可執行檔。舉例來說,如果你正在 `x86_64` 上使用 Windows那麼 Rust 就會嘗試編譯出 `.exe`,一個使用 `x86_64` 指令集的 Windows 執行檔。這樣的環境稱之為主機系統host system
為了描述不同環境Rust 使用 [_target triple_] 的字串。要查看目前系統的 target triple你可以執行 `rustc --version --verbose`
[_target triple_]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
```
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0
```
上面的輸出訊息來自 `x86_64` 上的 Linux 系統,我們可以看到 `host` 的 target triple 為 `x86_64-unknown-linux-gnu`,分別代表 CPU 架構 (`x86_64`)、供應商 (`unknown`) 以及作業系統 (`linux`) 和 [ABI] (`gnu`)。
[ABI]: https://en.wikipedia.org/wiki/Application_binary_interface
在依據主機的 triple 編譯時Rust 編譯器和連結器理所當然地會認為預設是底層的作業系統並使用 C 執行時系統,這便是造成錯誤的原因。要避免這項錯誤,我們可以選擇編譯出沒有底層作業系統的不同環境。
其中一個裸機環境的例子是 `thumbv7em-none-eabihf` target triple它描述了[嵌入式][embedded] [ARM] 系統。其中的細節目前並不重要,我們現在只需要知道沒有底層作業系統的 target triple 是用 `none` 描述的。想要編譯這樣的目標的話,我們需要將它新增至 rustup
[embedded]: https://en.wikipedia.org/wiki/Embedded_system
[ARM]: https://en.wikipedia.org/wiki/ARM_architecture
```
rustup target add thumbv7em-none-eabihf
```
這會下載一份該系統的標準(以及 core函式庫現在我們可以用此目標建立我們的獨立執行檔了
```
cargo build --target thumbv7em-none-eabihf
```
我們傳入 `--target` [交叉編譯][cross compile]我們在裸機系統的執行檔。因為目標系統沒有作業系統,連結器不會嘗試連結 C 執行時系統並成功建立,不會產生任何連結器錯誤。
[cross compile]: https://en.wikipedia.org/wiki/Cross_compiler
這將會是我們到時候用來建立自己的作業系統核心的方法。不過我們不會用到 `thumbv7em-none-eabihf`,我們將會使用[自訂目標][custom target]來描述一個 `x86_64` 的裸機環境。
[custom target]: https://doc.rust-lang.org/rustc/targets/custom.html
### 連結器引數
除了編譯裸機系統為目標以外,我們也可以傳入特定的引數組合給連結器來解決錯誤。這不會是我們到時候用在我們核心的方法,所以以下的內容不是必需的,只是用來補齊資訊。點選下面的 _「連結器引數」_ 來顯示額外資訊。
<details>
<summary>連結器引數</summary>
在這部份我們將討論 Linux、Windows 和 macOS 上發生的連結器錯誤,然後解釋如何傳入額外引數給連結器以解決錯誤。注意執行檔和連結器在不同作業系統之間都會相異,所以不同系統需要傳入不同引數。
#### Linux
以下是 Linux 上會出現的(簡化過)連結器錯誤:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status
```
問題的原因是因為連結器在一開始包含了 C 的執行時系統,而且剛好也叫做 `_start`。它需要一些 C 標準函式庫 `libc` 提供的符號,但我們用 `no_std` 來停用它了,所以連結器無法找出引用來源。我們可以用 `-nostartfiles` 來告訴連結器一開始不必連結 C 的執行時系統。
要傳入的其中一個方法是透過 cargo 的 `cargo rustc` 命令,此命令行為和 `cargo build` 一樣,不過允許傳入一些選項到 Rust 底層的編譯器 `rustc``rustc``-C link-arg` 的選項會繼續將引數傳到連結器,這樣一來我們的指令會長得像這樣:
```
cargo rustc -- -C link-arg=-nostartfiles
```
現在我們的 crate 便能產生出 Linux 上的獨立執行檔了!
我們不必再指明入口點的函式名稱,因為連結器預設會尋找 `_start` 函式。
#### Windows
在 Windows 上會出現不一樣的(簡化過)連結器錯誤:
```
error: linking with `link.exe` failed: exit code: 1561
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1561: entry point must be defined
```
"entry point must be defined" 錯誤表示連結器找不到入口點,在 Windows 上預設的入口點名稱會[依據使用的子系統][windows-subsystems]。如果是 `CONSOLE` 子系統的話,連結器會尋找 `mainCRTStartup` 函式名稱;而 `WINDOWS` 子系統的話則會尋找 `WinMainCRTStartup` 函式名稱。要覆蓋預設的選項並讓連結器尋找我們的 `_start` 函式的話,我們可以傳入 `/ENTRY` 引數給連結器:
[windows-subsystems]: https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol
```
cargo rustc -- -C link-arg=/ENTRY:_start
```
從引數格式來看我們可以清楚理解 Windows 連結器與 Linux 連結器是完全不同的程式。
現在會出現另一個連結器錯誤:
```
error: linking with `link.exe` failed: exit code: 1221
|
= note: "C:\\Program Files (x86)\\…\\link.exe" […]
= note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
defined
```
此錯誤出現的原因是因為 Windows 執行檔可以使用不同的[子系統][windows-subsystems]。一般的程式會依據入口點名稱來決定:如果入口點名稱為 `main` 則會使用 `CONSOLE` 子系統;如果入口點名稱為 `WinMain` 則會使用 `WINDOWS` 子系統。由於我們的函式 `_start` 名稱不一樣,我們必須指明子系統:
```
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
```
我們使用 `CONSOLE` 子系統不過 `WINDOWS` 一樣也可以。與其輸入好多次 `-C link-arg` ,我們可以用 `-C link-args` 來傳入許多引數。
使用此命令後,我們的執行檔應當能成功在 Windows 上建立。
#### macOS
以下是 Linux 上會出現的(簡化過)連結器錯誤:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: entry point (_main) undefined. for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
此錯誤訊息告訴我們連結器無法找到入口點函式 `main`,基於某些原因 macOS 上的函式都會加上前綴 `_`。為了設定入口點為我們的函式 `_start`,我們傳入 `-e` 連結器引數:
```
cargo rustc -- -C link-args="-e __start"
```
`-e` 表示入口點的函式名稱,然後由於 macOS 上所有的函式都會加上前綴 `_`,我們需要設置入口點為 `__start` 而不是 `_start`
接下來會出現另一個連結器錯誤:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: dynamic main executables must link with libSystem.dylib
for architecture x86_64
clang: error: linker command failed with exit code 1 […]
```
macOS [官方並不支援靜態連結執行檔][does not officially support statically linked binaries]且要求程式預設要連結到 `libSystem` 函式庫。要覆蓋這個設定並連結靜態執行檔,我們傳入 `-static` 給連結器:
[does not officially support statically linked binaries]: https://developer.apple.com/library/archive/qa/qa1118/_index.html
```
cargo rustc -- -C link-args="-e __start -static"
```
但這樣還不夠,我們會遇到第三個連結器錯誤:
```
error: linking with `cc` failed: exit code: 1
|
= note: "cc" […]
= note: ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 […]
```
這錯誤出現的原因是因為 macOS 的程式預設都會連結到 `crt0` (“C runtime zero”)。這和我們在 Linux 上遇到的類似,所以也可以用 `-nostartfiles` 連結器引數來解決:
```
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
現在我們的程式應當能成功在 macOS 上建立。
#### 統一建構命令
現在我們得依據主機平台來使用不同的建構命令,這樣感覺不是很理想。我們可以建立個檔案 `.cargo/config` 來解決,裡面會包含平台相關的引數:
```toml
# in .cargo/config
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]
```
`rustflags` 包含的引數會自動加到 `rustc` 如果條件符合的話。想了解更多關於 `.cargo/config` 的資訊請參考[官方文件][official documentation](https://doc.rust-lang.org/cargo/reference/config.html)。
這樣一來我們就能同時在三個平台只用 `cargo build` 來建立了。
#### 你該這麼作嗎?
雖然我們可以在 Linux、Windows 和 macOS 上建立獨立執行檔,不過這可能不是好主意。我們目前會需要這樣做的原因是因為我們的執行檔仍然需要仰賴一些事情,像是當 `_start` 函式呼叫時堆疊已經初始化完畢。少了 C 執行時系統,有些要求可能會無法達成,造成我們的程式失效,像是 segmentation fault。
如果你想要建立一個運行在已存作業系統上的最小執行檔,改用 `libc` 然後如這邊[所述](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html)設置 `#[start]` 屬性可能會是更好的做法。
</details>
## 總結 {#summary}
一個最小的 Rust 獨立執行檔會看起來像這樣:
`src/main.rs`
```rust
#![no_std] // 不連結標準函式庫
#![no_main] // 停用 Rust 層級的入口點
use core::panic::PanicInfo;
#[no_mangle] // 不修飾函式名稱
pub extern "C" fn _start() -> ! {
// 因為連結器預設會尋找 `_start` 函式名稱
// 所以這個函式就是入口點
loop {}
}
/// 此函式會在 panic 時呼叫
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
```
`Cargo.toml`
```toml
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# `cargo build` 時需要的設置
[profile.dev]
panic = "abort" # 停用 panic 時堆疊回溯
# `cargo build --release` 時需要的設置
[profile.release]
panic = "abort" # 停用 panic 時堆疊回溯
```
要建構出此執行檔,我們需要選擇一個裸機目標來編譯像是 `thumbv7em-none-eabihf`
```
cargo build --target thumbv7em-none-eabihf
```
不然我們也可以用主機系統來編譯,不過要加上額外的連結器引數:
```bash
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
```
注意這只是最小的 Rust 獨立執行檔範例,它還是會仰賴一些事情發生,像是當 `_start` 函式呼叫時堆疊已經初始化完畢。**所以如果想真的使用這樣的執行檔的話還需要更多步驟。**
## 接下來呢?
[下一篇文章][next post] 將會講解如何將我們的獨立執行檔轉成最小的作業系統核心。這包含建立自訂目標、用啟動程式組合我們的執行檔,還有學習如何輸出一些東西到螢幕上。
[next post]: @/edition-2/posts/02-minimal-rust-kernel/index.md

View File

@@ -1,6 +0,0 @@
+++
title = "Extra Posts for Minimal Rust Kernel"
sort_by = "weight"
insert_anchor_links = "left"
render = false
+++

View File

@@ -1,29 +0,0 @@
+++
title = "Red Zone 기능 해제하기"
weight = 1
path = "ko/red-zone"
template = "edition-2/extra.html"
+++
[red zone]은 [System V ABI]에서 사용 가능한 최적화 기법으로, 스택 포인터를 변경하지 않은 채로 함수들이 임시적으로 스택 프레임 아래의 128 바이트 공간을 사용할 수 있게 해줍니다:
[red zone]: https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64#the-red-zone
[System V ABI]: https://wiki.osdev.org/System_V_ABI
<!-- more -->
![stack frame with red zone](red-zone.svg)
위 사진은 `n`개의 지역 변수를 가진 함수의 스택 프레임을 보여줍니다. 함수가 호출되었을 때, 함수의 반환 주소 및 지역 변수들을 스택에 저장할 수 있도록 스택 포인터의 값이 조정됩니다.
red zone은 조정된 스택 포인터 아래의 128바이트의 메모리 구간을 가리킵니다. 함수가 또 다른 함수를 호출하지 않는 구간에서만 사용하는 임시 데이터의 경우, 함수가 이 구간에 해당 데이터를 저장하는 데 이용할 수 있습니다. 따라서 스택 포인터를 조정하기 위해 필요한 명령어 두 개를 생략할 수 있는 상황이 종종 있습니다 (예: 다른 함수를 호출하지 않는 함수).
하지만 이 최적화 기법을 사용하는 도중 소프트웨어 예외(exception) 혹은 하드웨어 인터럽트가 일어날 경우 큰 문제가 생깁니다. 함수가 red zone을 사용하던 도중 예외가 발생한 상황을 가정해보겠습니다:
![red zone overwritten by exception handler](red-zone-overwrite.svg)
CPU와 예외 처리 핸들러가 red zone에 있는 데이터를 덮어씁니다. 하지만 이 데이터는 인터럽트된 함수가 사용 중이었던 것입니다. 따라서 예외 처리 핸들러로부터 반환하여 다시 인터럽트된 함수가 계속 실행되게 되었을 때 변경된 red zone의 데이터로 인해 함수가 오작동할 수 있습니다. 이런 현상으로 인해 [디버깅하는 데에 몇 주씩 걸릴 수 있는 이상한 버그][take weeks to debug]가 발생할지도 모릅니다.
[take weeks to debug]: https://forum.osdev.org/viewtopic.php?t=21720
미래에 예외 처리 로직을 구현할 때 이러한 오류가 일어나는 것을 피하기 위해 우리는 미리 red zone 최적화 기법을 해제한 채로 프로젝트를 진행할 것입니다. 컴파일 대상 환경 설정 파일에 `"disable-redzone": true` 줄을 추가함으로써 해당 기능을 해제할 수 있습니다.

View File

@@ -1,29 +0,0 @@
+++
title = "Отключение красной зоны"
weight = 1
path = "ru/red-zone"
template = "edition-2/extra.html"
+++
[Красная зона][red zone] &mdash; это оптимизация [System V ABI], которая позволяет функциям временно использовать 128 байт ниже своего стекового кадра без корректировки указателя стека:
[red zone]: https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64#the-red-zone
[System V ABI]: https://wiki.osdev.org/System_V_ABI
<!-- more -->
![stack frame with red zone](red-zone.svg)
На рисунке показан стековый фрейм функции с `n` локальных переменных. При входе в функцию указатель стека корректируется, чтобы освободить место в стеке для адреса возврата и локальных переменных.
Красная зона определяется как 128 байт ниже скорректированного указателя стека. Функция может использовать эту зону для временных данных, которые не нужны при всех вызовах функции. Таким образом, в некоторых случаях (например, в небольших листовых функциях) можно обойтись без двух инструкций для корректировки указателя стека.
Однако такая оптимизация приводит к огромным проблемам при работе с исключениями или аппаратными прерываниями. Предположим, что во время использования функцией красной зоны происходит исключение:
![red zone overwritten by exception handler](red-zone-overwrite.svg)
Процессор и обработчик исключений перезаписывают данные в красной зоне. Но эти данные все еще нужны прерванной функции. Поэтому функция не будет работать правильно, когда мы вернемся из обработчика исключений. Это может привести к странным ошибкам, на отладку которых [уйдут недели][take weeks to debug].
[take weeks to debug]: https://forum.osdev.org/viewtopic.php?t=21720
Чтобы избежать подобных ошибок при реализации обработки исключений в будущем, мы отключим красную зону с самого начала. Это достигается путем добавления строки `"disable-redzone": true` в наш целевой конфигурационный файл.

View File

@@ -1,29 +0,0 @@
+++
title = "Disable the Red Zone"
weight = 1
path = "zh-CN/red-zone"
template = "edition-2/extra.html"
+++
[红区][red zone] 是 [System V ABI] 提供的一种优化技术它使得函数可以在不修改栈指针的前提下临时使用其栈帧下方的128个字节。
[red zone]: https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64#the-red-zone
[System V ABI]: https://wiki.osdev.org/System_V_ABI
<!-- more -->
![stack frame with red zone](red-zone.svg)
上图展示了一个包含了 `n` 个局部变量的栈帧。当方法开始执行时,栈指针会被调整到一个合适的位置,为返回值和局部变量留出足够的空间。
红区是位于调整后的栈指针下方长度为128字节的区域函数会使用这部分空间存储不会被跨函数调用的临时数据。所以在某些情况下比如逻辑简短的叶函数红区可以节省用于调整栈指针的两条机器指令。
然而红区优化有时也会引发无法处理的巨大问题(异常或者硬件中断),如果使用红区时发生了某种异常:
![red zone overwritten by exception handler](red-zone-overwrite.svg)
CPU和异常处理机制会把红色区域内的数据覆盖掉但是被中断的函数依然在引用着这些数据。当函数从错误中恢复时错误的数据就会引发更大的错误这类错误往往需要[追踪数周][take weeks to debug]才能找到。
[take weeks to debug]: https://forum.osdev.org/viewtopic.php?t=21720
要在编写异常处理机制时避免这些隐蔽而难以追踪的bug我们需要从一开始就禁用红区优化具体到配置文件中的配置项就是 `"disable-redzone": true`

View File

@@ -1,45 +0,0 @@
+++
title = "SIMD 해제하기"
weight = 2
path = "ko/disable-simd"
template = "edition-2/extra.html"
+++
[Single Instruction Multiple Data (SIMD)] 명령어들은 여러 데이터 word에 동시에 덧셈 등의 작업을 실행할 수 있으며, 이를 통해 프로그램의 실행 시간을 상당히 단축할 수 있습니다. `x86_64` 아키텍처는 다양한 SIMD 표준들을 지원합니다:
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
<!-- more -->
- [MMX]: _Multi Media Extension_ 명령어 집합은 1997년에 등장하였으며, `mm0`에서 `mm7`까지 8개의 64비트 레지스터들을 정의합니다. 이 레지스터들은 그저 [x87 부동 소수점 장치][x87 floating point unit]의 레지스터들을 가리키는 별칭입니다.
- [SSE]: _Streaming SIMD Extensions_ 명령어 집합은 1999년에 등장하였습니다. 부동 소수점 연산용 레지스터를 재사용하는 대신 새로운 레지스터 집합을 도입했습니다. `xmm0`에서 `xmm15`까지 16개의 새로운 128비트 레지스터를 정의합니다.
- [AVX]: _Advanced Vector Extensions_ 은 SSE에 추가로 멀티미디어 레지스터의 크기를 늘리는 확장 표준입니다. `ymm0`에서 `ymm15`까지 16개의 새로운 256비트 레지스터를 정의합니다. `ymm` 레지스터들은 기존의 `xmm` 레지스터를 확장합니다 (`xmm0``ymm0` 레지스터의 하부 절반을 차지하는 식으로 다른 15개의 짝에도 같은 방식의 확장이 적용됩니다).
[MMX]: https://en.wikipedia.org/wiki/MMX_(instruction_set)
[x87 floating point unit]: https://en.wikipedia.org/wiki/X87
[SSE]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
[AVX]: https://en.wikipedia.org/wiki/Advanced_Vector_Extensions
이러한 SIMD 표준들을 사용하면 프로그램 실행 속도를 많이 향상할 수 있는 경우가 많습니다. 우수한 컴파일러는 [자동 벡터화 (auto-vectorization)][auto-vectorization]이라는 과정을 통해 일반적인 반복문을 SIMD 코드로 변환할 수 있습니다.
[auto-vectorization]: https://en.wikipedia.org/wiki/Automatic_vectorization
하지만 운영체제 커널은 크기가 큰 SIMD 레지스터들을 사용하기에 문제가 있습니다. 그 이유는 하드웨어 인터럽트가 일어날 때마다 커널이 사용 중이던 레지스터들의 상태를 전부 메모리에 백업해야 하기 때문입니다. 이렇게 하지 않으면 인터럽트 되었던 프로그램의 실행이 다시 진행될 때 인터럽트 당시의 프로그램 상태를 보존할 수가 없습니다. 따라서 커널이 SIMD 레지스터들을 사용하는 경우, 커널이 백업해야 하는 데이터 양이 많이 늘어나게 되어 (512-1600 바이트) 커널의 성능이 눈에 띄게 나빠집니다. 이러한 성능 손실을 피하기 위해서 `sse``mmx` 기능을 해제하는 것이 바람직합니다 (`avx` 기능은 해제된 상태가 기본 상태입니다).
컴파일 대상 환경 설정 파일의 `features` 필드를 이용해 해당 기능들을 해제할 수 있습니다. `mmx``sse` 기능을 해제하려면 아래와 같이 해당 기능 이름 앞에 빼기 기호를 붙여주면 됩니다:
```json
"features": "-mmx,-sse"
```
## 부동소수점 (Floating Point)
우리의 입장에서는 안타깝게도, `x86_64` 아키텍처는 부동 소수점 계산에 SSE 레지스터를 사용합니다. 따라서 SSE 기능이 해제된 상태에서 부동 소수점 계산을 컴파일하면 LLVM이 오류를 일으킵니다. Rust의 core 라이브러리는 이미 부동 소수점 숫자들을 사용하기에 (예: `f32``f64` 에 대한 각종 trait들을 정의함), 우리의 커널에서 부동 소수점 계산을 피하더라도 부동 소수점 계산을 컴파일하는 것을 피할 수 없습니다.
다행히도 LLVM은 `soft-float` 기능을 지원합니다. 이 기능을 통해 정수 계만으로 모든 부동소수점 연산 결과를 모방하여 산출할 수 있습니다. 일반 부동소수점 계산보다는 느리겠지만, 이 기능을 통해 우리의 커널에서도 SSE 기능 없이 부동소수점을 사용할 수 있습니다.
우리의 커널에서 `soft-float` 기능을 사용하려면 컴파일 대상 환경 설정 파일의 `features` 필드에 덧셈 기호와 함께 해당 기능의 이름을 적어주면 됩니다:
```json
"features": "-mmx,-sse,+soft-float"
```

View File

@@ -1,44 +0,0 @@
+++
title = "Отключение SIMD"
weight = 2
path = "ru/disable-simd"
template = "edition-2/extra.html"
+++
Инструкции [Single Instruction Multiple Data (SIMD)] способны выполнять операцию (например, сложение) одновременно над несколькими словами данных, что может значительно ускорить работу программ. Архитектура `x86_64` поддерживает различные стандарты SIMD:
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
<!-- more -->
- [MMX]: Набор инструкций _Multi Media Extension_ был представлен в 1997 году и определяет восемь 64-битных регистров, называемых `mm0` - `mm7`. Эти регистры являются псевдонимами регистров [x87 блока с плавающей запятой][x87 floating point unit].
- [SSE]: Набор инструкций _Streaming SIMD Extensions_ был представлен в 1999 году. Вместо повторного использования регистров с плавающей запятой он добавляет совершенно новый набор регистров. Шестнадцать новых регистров называются `xmm0` - `xmm15` и имеют размер 128 бит каждый.
- [AVX]: _Advanced Vector Extensions_ - это расширения, которые еще больше увеличивают размер мультимедийных регистров. Новые регистры называются `ymm0` - `ymm15` и имеют размер 256 бит каждый. Они расширяют регистры `xmm`, поэтому, например, `xmm0` - это нижняя половина `ymm0`.
[MMX]: https://en.wikipedia.org/wiki/MMX_(instruction_set)
[x87 floating point unit]: https://en.wikipedia.org/wiki/X87
[SSE]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
[AVX]: https://en.wikipedia.org/wiki/Advanced_Vector_Extensions
Используя такие стандарты SIMD, программы часто могут значительно ускориться. Хорошие компиляторы способны автоматически преобразовывать обычные циклы в такой SIMD-код с помощью процесса, называемого [автовекторизацией][auto-vectorization].
[auto-vectorization]: https://en.wikipedia.org/wiki/Automatic_vectorization
Однако большие регистры SIMD приводят к проблемам в ядрах ОС. Причина в том, что ядро должно создавать резервные копии всех регистров, которые оно использует, в память при каждом аппаратном прерывании, потому что они должны иметь свои первоначальные значения, когда прерванная программа продолжает работу. Поэтому, если ядро использует SIMD-регистры, ему приходится резервировать гораздо больше данных (512-1600 байт), что заметно снижает производительность. Чтобы избежать этого снижения производительности, мы хотим отключить функции `sse` и `mmx` (функция `avx` отключена по умолчанию).
Мы можем сделать это через поле `features` в нашей целевой спецификации. Чтобы отключить функции `mmx` и `sse`, мы добавим их с минусом:
```json
"features": "-mmx,-sse"
```
## Числа с плавающей точкой
К сожалению для нас, архитектура `x86_64` использует регистры SSE для операций с числами с плавающей точкой. Таким образом, каждое использование чисел с плавающей точкой с отключенным SSE вызовёт ошибку в LLVM. Проблема в том, что библиотека `core` уже использует числа с плавающей точкой (например, в ней реализованы трейты для `f32` и `f64`), поэтому недостаточно избегать чисел с плавающей точкой в нашем ядре.
К счастью, LLVM поддерживает функцию `soft-float`, эмулирующую все операции с числавами с плавающей точкой через программные функции, основанные на обычных целых числах. Это позволяет использовать плавающие числа в нашем ядре без SSE, просто это будет немного медленнее.
Чтобы включить функцию `soft-float` для нашего ядра, мы добавим ее в строку `features` в спецификации цели с префиксом плюс:
```json
"features": "-mmx,-sse,+soft-float"
```

View File

@@ -1,44 +0,0 @@
+++
title = "Disable SIMD"
weight = 2
path = "zh-CN/disable-simd"
template = "edition-2/extra.html"
+++
[单指令多数据][Single Instruction Multiple Data (SIMD)] 指令允许在一个操作符(比如加法)内传入多组数据,以此加速程序执行速度。`x86_64` 架构支持多种SIMD标准
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
<!-- more -->
- [MMX]: _多媒体扩展_ 指令集于1997年发布定义了8个64位寄存器分别被称为 `mm0``mm7`,不过,这些寄存器只是 [x87浮点执行单元][x87 floating point unit] 中寄存器的映射而已。
- [SSE]: _流处理SIMD扩展_ 指令集于1999年发布不同于MMX的复用浮点执行单元该指令集加入了一个完整的新寄存器组即被称为 `xmm0``xmm15` 的16个128位寄存器。
- [AVX]: _先进矢量扩展_ 用于进一步扩展多媒体寄存器的数量,它定义了 `ymm0``ymm15` 共16个256位寄存器但是这些寄存器继承于 `xmm`,例如 `xmm0` 寄存器是 `ymm0` 的低128位。
[MMX]: https://en.wikipedia.org/wiki/MMX_(instruction_set)
[x87 floating point unit]: https://en.wikipedia.org/wiki/X87
[SSE]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
[AVX]: https://en.wikipedia.org/wiki/Advanced_Vector_Extensions
通过应用这些SIMD标准计算机程序可以显著提高执行速度。优秀的编译器可以将常规循环自动优化为适用SIMD的代码这种优化技术被称为 [自动矢量化][auto-vectorization]。
[auto-vectorization]: https://en.wikipedia.org/wiki/Automatic_vectorization
尽管如此SIMD会让操作系统内核出现一些问题。具体来说就是操作系统在处理硬件中断时需要保存所有寄存器信息到内存中在中断结束后再将其恢复以供使用。所以说如果内核需要使用SIMD寄存器那么每次处理中断需要备份非常多的数据512-1600字节这会显著地降低性能。要避免这部分性能损失我们需要禁用 `sse``mmx` 这两个特性(`avx` 默认已禁用)。
我们可以在编译配置文件中的 `features` 配置项做出如下修改,加入以减号为前缀的 `mmx``sse` 即可:
```json
"features": "-mmx,-sse"
```
## 浮点数
还有一件不幸的事,`x86_64` 架构在处理浮点数计算时,会用到 `sse` 寄存器因此禁用SSE的前提下使用浮点数计算LLVM都一定会报错。 更大的问题在于Rust核心库里就存在着为数不少的浮点数运算`f32``f64` 的数个trait所以试图避免使用浮点数是不可能的。
幸运的是LLVM支持 `soft-float` 特性这个特性可以使用整型运算在软件层面模拟浮点数运算使得我们为内核关闭SSE成为了可能只需要牺牲一点点性能。
要为内核打开 `soft-float` 特性,我们只需要在编译配置文件中的 `features` 配置项做出如下修改即可:
```json
"features": "-mmx,-sse,+soft-float"
```

View File

@@ -1,504 +0,0 @@
+++
title = "یک هسته مینیمال با Rust"
weight = 2
path = "fa/minimal-rust-kernel"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "7212ffaa8383122b1eb07fe1854814f99d2e1af4"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
در این پست ما برای معماری x86 یک هسته مینیمال ۶۴ بیتی به زبان راست می‌سازیم. با استفاده از باینری مستقل Rust از پست قبل، یک دیسک ایمیج قابل بوت می‌سازیم، که متنی را در صفحه چاپ کند.
[باینری مستقل Rust]: @/edition-2/posts/01-freestanding-rust-binary/index.md
<!-- more -->
این بلاگ بصورت آزاد روی [گیت‌هاب] توسعه داده شده است. اگر شما مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. شما همچنین می‌توانید [در زیر] این پست کامنت بگذارید. منبع کد کامل این پست را می‌توانید در بِرَنچ [`post-02`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## فرآیند بوت شدن
وقتی یک رایانه را روشن می‌کنید، شروع به اجرای کد فِرْم‌وِر (کلمه: firmware) ذخیره شده در [ROM] مادربرد می‌کند. این کد یک [power-on self-test] انجام می‌دهد، رم موجود را تشخیص داده، و پردازنده و سخت افزار را پیش‌ مقداردهی اولیه می‌کند. پس از آن به یک دنبال دیسک قابل بوت می‌گردد و شروع به بوت کردن هسته سیستم عامل می‌کند.
[ROM]: https://en.wikipedia.org/wiki/Read-only_memory
[power-on self-test]: https://en.wikipedia.org/wiki/Power-on_self-test
در x86، دو استاندارد فِرْم‌وِر (کلمه: firmware) وجود دارد: «سامانهٔ ورودی/خروجیِ پایه» (**[BIOS]**) و استاندارد جدیدتر «رابط فِرْم‌وِر توسعه یافته یکپارچه» (**[UEFI]**). استاندارد BIOS قدیمی و منسوخ است، اما ساده است و از دهه ۱۹۸۰ تاکنون در هر دستگاه x86 کاملاً پشتیبانی می‌شود. در مقابل‌، UEFI مدرن‌تر است و ویژگی‌های بسیار بیشتری دارد‌، اما راه اندازی آن پیچیده‌تر است (حداقل به نظر من).
[BIOS]: https://en.wikipedia.org/wiki/BIOS
[UEFI]: https://en.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface
در حال حاضر، ما فقط پشتیبانی BIOS را ارائه می‌دهیم، اما پشتیبانی از UEFI نیز برنامه‌ریزی شده است. اگر می‌خواهید در این زمینه به ما کمک کنید، [ایشو گیت‌هاب](https://github.com/phil-opp/blog_os/issues/349) را بررسی کنید.
### بوت شدن BIOS
تقریباً همه سیستم‌های x86 از بوت شدن BIOS پشتیبانی می‌کنند‌، از جمله سیستم‌های جدیدترِ مبتنی بر UEFI که از BIOS شبیه‌سازی شده استفاده می‌کنند. این عالی است‌، زیرا شما می‌توانید از منطق بوت یکسانی در تمام سیستم‌های قرن‌های گذشته استفاده کنید. اما این سازگاری گسترده در عین حال بزرگترین نقطه ضعف راه‌‌اندازی BIOS است، زیرا این بدان معناست که پردازنده قبل از بوت شدن در یک حالت سازگاری 16 بیتی به نام [real mode] قرار داده می‌شود تا بوت‌لودرهای قدیمی از دهه 1980 همچنان کار کنند.
اما بیایید از ابتدا شروع کنیم:
وقتی یک رایانه را روشن می‌کنید، BIOS را از حافظه فلش مخصوصی که روی مادربرد قرار دارد بارگذاری می‌کند. BIOS روال‌های خودآزمایی و مقداردهی اولیه سخت افزار را اجرا می کند‌، سپس به دنبال دیسک‌های قابل بوت می‌گردد. اگر یکی را پیدا کند، کنترل به _بوت‌لودرِ_ آن منتقل می‌شود‌، که یک قسمت ۵۱۲ بایتی از کد اجرایی است و در ابتدای دیسک ذخیره شده است. بیشتر بوت‌لودرها از ۵۱۲ بایت بزرگتر هستند، بنابراین بوت‌لودرها معمولاً به یک قسمت کوچک ابتدایی تقسیم می‌شوند که در ۵۱۲ بایت جای می‌گیرد و قسمت دوم که متعاقباً توسط قسمت اول بارگذاری می‌شود.
بوت‌لودر باید محل ایمیج هسته را بر روی دیسک تعیین کرده و آن را در حافظه بارگذاری کند. همچنین ابتدا باید CPU را از [real mode] (ترجمه: حالت واقعی) 16 بیتی به [protected mode] (ترجمه: حالت محافظت شده) 32 بیتی و سپس به [long mode] (ترجمه: حالت طولانی) 64 بیتی سوییچ کند، جایی که ثبات‌های 64 بیتی و کل حافظه اصلی در آن در دسترس هستند. کار سوم آن پرس‌وجو درباره اطلاعات خاص (مانند نگاشت حافظه) از BIOS و انتقال آن به هسته سیستم عامل است.
[real mode]: https://en.wikipedia.org/wiki/Real_mode
[protected mode]: https://en.wikipedia.org/wiki/Protected_mode
[long mode]: https://en.wikipedia.org/wiki/Long_mode
[memory segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
نوشتن بوت‌لودر کمی دشوار است زیرا به زبان اسمبلی و بسیاری از مراحل غیر بصیرانه مانند "نوشتن این مقدار جادویی در این ثبات پردازنده" نیاز دارد. بنابراین ما در این پست ایجاد بوت‌لودر را پوشش نمی‌دهیم و در عوض ابزاری به نام [bootimage] را ارائه می‌دهیم که بوت‌لودر را به طور خودکار به هسته شما اضافه می‌کند.
[bootimage]: https://github.com/rust-osdev/bootimage
اگر علاقه‌مند به ساخت بوت‌لودر هستید: با ما همراه باشید‌، مجموعه‌ای از پست‌ها در این زمینه از قبل برنامه‌ریزی شده است! <!-- , check out our “_[Writing a Bootloader]_” posts, where we explain in detail how a bootloader is built. -->
#### استاندارد بوت چندگانه
برای جلوگیری از این که هر سیستم عاملی بوت‌لودر خود را پیاده‌سازی کند، که فقط با یک سیستم عامل سازگار است، [بنیاد نرم افزار آزاد] در سال 1995 یک استاندارد بوت‌لودر آزاد به نام [Multiboot] ایجاد کرد. این استاندارد یک رابط بین بوت‌لودر و سیستم عامل را تعریف می‌کند، به طوری که هر بوت‌لودر سازگار با Multiboot می‌تواند هر سیستم عامل سازگار با Multiboot را بارگذاری کند. پیاده‌سازی مرجع [GNU GRUB] است که محبوب‌ترین بوت‌لودر برای سیستم‌های لینوکس است.
[بنیاد نرم افزار آزاد]: https://en.wikipedia.org/wiki/Free_Software_Foundation
[Multiboot]: https://wiki.osdev.org/Multiboot
[GNU GRUB]: https://en.wikipedia.org/wiki/GNU_GRUB
برای سازگار کردن هسته با Multiboot، کافیست یک به اصطلاح [Multiboot header] را در ابتدای فایل هسته اضافه کنید. با این کار بوت کردن سیستم عامل در GRUB بسیار آسان خواهد شد. با این حال، GRUB و استاندارد Multiboot نیز دارای برخی مشکلات هستند:
[Multiboot header]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format
- آنها فقط از حالت محافظت شده 32 بیتی پشتیبانی می‌کنند. این بدان معناست که شما برای تغییر به حالت طولانی 64 بیتی هنوز باید پیکربندی CPU را انجام دهید.
- آنها برای ساده سازی بوت‌لودر طراحی شده‌اند نه برای ساده سازی هسته. به عنوان مثال، هسته باید با [اندازه صفحه پیش فرض تنظیم شده] پیوند داده شود، زیرا GRUB در غیر اینصورت نمی‌تواند هدر Multiboot را پیدا کند. مثال دیگر این است که [اطلاعات بوت]، که به هسته منتقل می‌شوند‌، به جای ارائه انتزاعات تمیز و واضح، شامل ساختارها با وابستگی زیاد به معماری هستند.
- هر دو استاندارد GRUB و Multiboot بصورت ناقص مستند شده‌اند.
- برای ایجاد یک ایمیج دیسکِ قابل بوت از فایل هسته، GRUB باید روی سیستم میزبان نصب شود. این امر باعث دشوارتر شدنِ توسعه در ویندوز یا Mac می‌شود.
[اندازه صفحه پیش فرض تنظیم شده]: https://wiki.osdev.org/Multiboot#Multiboot_2
[اطلاعات بوت]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format
به دلیل این اشکالات ما تصمیم گرفتیم از GRUB یا استاندارد Multiboot استفاده نکنیم. با این حال، ما قصد داریم پشتیبانی Multiboot را به ابزار [bootimage] خود اضافه کنیم، به طوری که امکان بارگذاری هسته شما بر روی یک سیستم با بوت‌لودر GRUB نیز وجود داشته باشد. اگر علاقه‌مند به نوشتن هسته سازگار با Multiboot هستید، [نسخه اول] مجموعه پست‌های این وبلاگ را بررسی کنید.
[نسخه اول]: @/edition-1/_index.md
### UEFI
(ما در حال حاضر پشتیبانی UEFI را ارائه نمی‌دهیم، اما خیلی دوست داریم این کار را انجام دهیم! اگر می‌خواهید کمک کنید، لطفاً در [ایشو گیت‌هاب](https://github.com/phil-opp/blog_os/issues/349) به ما بگویید.)
## یک هسته مینیمال
اکنون که تقریباً می‌دانیم چگونه یک کامپیوتر بوت می‌شود، وقت آن است که هسته مینیمال خودمان را ایجاد کنیم. هدف ما ایجاد دیسک ایمیجی می‌باشد که “!Hello World” را هنگام بوت شدن چاپ کند. برای این منظور از [باینری مستقل Rust] که در پست قبل دیدید استفاده می‌کنیم.
همانطور که ممکن است به یاد داشته باشید، باینری مستقل را از طریق `cargo` ایجاد کردیم، اما با توجه به سیستم عامل، به نام‌های ورودی و پرچم‌های کامپایل مختلف نیاز داشتیم. به این دلیل که `cargo` به طور پیش فرض برای سیستم میزبان بیلد می‌کند، بطور مثال سیستمی که از آن برای نوشتن هسته استفاده می‌کنید. این چیزی نیست که ما برای هسته خود بخواهیم‌، زیرا منطقی نیست که هسته سیستم عامل‌مان را روی یک سیستم عامل دیگر اجرا کنیم. در عوض، ما می‌خواهیم هسته را برای یک _سیستم هدف_ کاملاً مشخص کامپایل کنیم.
### نصب Rust Nightly {#installing-rust-nightly}
راست دارای سه کانال انتشار است: _stable_, _beta_, and _nightly_ (ترجمه از چپ به راست: پایدار، بتا و شبانه). کتاب Rust تفاوت بین این کانال‌ها را به خوبی توضیح می‌دهد، بنابراین یک دقیقه وقت بگذارید و [آن را بررسی کنید](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html#choo-choo-release-channels-and-riding-the-trains). برای ساخت یک سیستم عامل به برخی از ویژگی‌های آزمایشی نیاز داریم که فقط در کانال شبانه موجود است‌، بنابراین باید نسخه شبانه Rust را نصب کنیم.
برای مدیریت نصب‌های Rust من به شدت [rustup] را توصیه می‌کنم. به شما این امکان را می‌دهد که کامپایلرهای شبانه، بتا و پایدار را در کنار هم نصب کنید و بروزرسانی آنها را آسان می‌کند. با rustup شما می‌توانید از یک کامپایلر شبانه برای دایرکتوری جاری استفاده کنید، کافیست دستور `rustup override set nightly` را اجرا کنید. همچنین می‌توانید فایلی به نام `rust-toolchain` را با محتوای `nightly` در دایرکتوری ریشه پروژه اضافه کنید. با اجرای `rustc --version` می‌توانید چک کنید که نسخه شبانه را دارید یا نه. شماره نسخه باید در پایان شامل `nightly-` باشد.
[rustup]: https://www.rustup.rs/
کامپایلر شبانه به ما امکان می‌دهد با استفاده از به اصطلاح _feature flags_ در بالای فایل، از ویژگی‌های مختلف آزمایشی استفاده کنیم. به عنوان مثال، می‌توانیم [`asm!` macro] آزمایشی را برای اجرای دستورات اسمبلیِ این‌لاین (تلفظ: inline) با اضافه کردن `[feature(asm)]!#` به بالای فایل `main.rs` فعال کنیم. توجه داشته باشید که این ویژگی‌های آزمایشی، کاملاً ناپایدار هستند‌، به این معنی که نسخه‌های آتی Rust ممکن است بدون هشدار قبلی آن‌ها را تغییر داده یا حذف کند. به همین دلیل ما فقط در صورت لزوم از آنها استفاده خواهیم کرد.
[`asm!` macro]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### مشخصات هدف
کارگو (کلمه: cargo) سیستم‌های هدف‌ مختلف را از طریق `target--` پشتیبانی می‌کند. سیستم هدف توسط یک به اصطلاح _[target triple]_ (ترجمه: هدف سه گانه) توصیف شده‌ است، که معماری CPU، فروشنده، سیستم عامل، و [ABI] را شامل می‌شود. برای مثال، هدف سه گانه `x86_64-unknown-linux-gnu` یک سیستم را توصیف می‌کند که دارای سی‌پی‌یو `x86_64`، بدون فروشنده مشخص و یک سیستم عامل لینوکس با GNU ABI است. Rust از [هدف‌های سه گانه مختلفی][platform-support] پشتیبانی می‌کند، شامل `arm-linux-androideabi` برای اندروید یا [`wasm32-unknown-unknown` برای وب‌اسمبلی](https://www.hellorust.com/setup/wasm-target/).
[target triple]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
[ABI]: https://stackoverflow.com/a/2456882
[platform-support]: https://forge.rust-lang.org/release/platform-support.html
[custom-targets]: https://doc.rust-lang.org/nightly/rustc/targets/custom.html
برای سیستم هدف خود، به برخی از پارامترهای خاص پیکربندی نیاز داریم (به عنوان مثال، فاقد سیستم عامل زیرین)، بنابراین هیچ یک از [اهداف سه گانه موجود][platform-support] مناسب نیست. خوشبختانه Rust به ما اجازه می‌دهد تا [هدف خود][custom-targets] را از طریق یک فایل JSON تعریف کنیم. به عنوان مثال، یک فایل JSON که هدف `x86_64-unknown-linux-gnu` را توصیف می‌کند به این شکل است:
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
اکثر فیلدها برای LLVM مورد نیاز هستند تا بتواند کد را برای آن پلتفرم ایجاد کند. برای مثال، فیلد [`data-layout`] اندازه انواع مختلف عدد صحیح، مُمَیزِ شناور و انواع اشاره‌گر را تعریف می‌کند. سپس فیلد‌هایی وجود دارد که Rust برای کامپایل شرطی از آن‌ها استفاده می‌کند، مانند `target-pointer-width`. نوع سوم فیلدها نحوه ساخت crate (تلفظ: کرِیت) را تعریف می‌کنند. مثلا، فیلد `pre-link-args` آرگومان‌های منتقل شده به [لینکر] را مشخص می‌کند.
[`data-layout`]: https://llvm.org/docs/LangRef.html#data-layout
[لینکر]: https://en.wikipedia.org/wiki/Linker_(computing)
ما همچنین سیستم‌های `x86_64` را با هسته خود مورد هدف قرار می‌دهیم‌، بنابراین مشخصات هدف ما بسیار شبیه به مورد بالا خواهد بود. بیایید با ایجاد یک فایل `x86_64-blog_os.json` شروع کنیم (هر اسمی را که دوست دارید انتخاب کنید) با محتوای مشترک:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
توجه داشته باشید که ما OS را در `llvm-target` و همچنین فیلد `os` را به `none` تغییر دادیم، زیرا ما هسته را روی یک bare metal اجرا می‌کنیم.
همچنین موارد زیر که مربوط به ساخت (ترجمه: build-related) هستند را اضافه می‌کنیم:
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
به جای استفاده از لینکر پیش فرض پلتفرم (که ممکن است از اهداف لینوکس پشتیبانی نکند)، ما از لینکر کراس پلتفرم [LLD] استفاده می‌کنیم که برای پیوند دادن هسته ما با Rust ارائه می‌شود.
[LLD]: https://lld.llvm.org/
```json
"panic-strategy": "abort",
```
این تنظیم مشخص می‌کند که هدف از [stack unwinding] درهنگام panic پشتیبانی نمی‌کند، بنابراین به جای آن خود برنامه باید مستقیماً متوقف شود. این همان اثر است که آپشن `panic = "abort"` در فایل Cargo.toml دارد، پس میتوانیم آن را از فایل Cargo.toml حذف کنیم.(توجه داشته باشید که این آپشنِ هدف همچنین زمانی اعمال می‌شود که ما کتابخانه `هسته` را مجددا در ادامه همین پست کامپایل می‌‌کنیم. بنابراین حتماً این گزینه را اضافه کنید، حتی اگر ترجیح می دهید گزینه Cargo.toml را حفظ کنید.)
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
```json
"disable-redzone": true,
```
ما در حال نوشتن یک هسته هستیم‌، بنابراین بالاخره باید وقفه‌ها را مدیریت کنیم. برای انجام ایمن آن، باید بهینه‌سازی اشاره‌گر پشته‌ای خاصی به نام _“red zone”_ (ترجمه: منطقه قرمز) را غیرفعال کنیم، زیرا در غیر این صورت باعث خراب شدن پشته می‌شود. برای اطلاعات بیشتر، به پست جداگانه ما در مورد [غیرفعال کردن منطقه قرمز] مراجعه کنید.
[غیرفعال کردن منطقه قرمز]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.md
```json
"features": "-mmx,-sse,+soft-float",
```
فیلد `features` ویژگی‌های هدف را فعال/غیرفعال می‌کند. ما ویژگی‌های `mmx` و `sse` را با گذاشتن یک منفی در ابتدای آن‌ها غیرفعال کردیم و ویژگی `soft-float` را با اضافه کردن یک مثبت به ابتدای آن فعال کردیم. توجه داشته باشید که بین پرچم‌های مختلف نباید فاصله‌ای وجود داشته باشد، در غیر این صورت LLVM قادر به تفسیر رشته ویژگی‌ها نیست.
ویژگی‌های `mmx` و `sse` پشتیبانی از دستورالعمل‌های [Single Instruction Multiple Data (SIMD)] را تعیین می‌کنند، که اغلب می‌تواند سرعت برنامه‌ها را به میزان قابل توجهی افزایش دهد. با این حال، استفاده از ثبات‌های بزرگ SIMD در هسته سیستم عامل منجر به مشکلات عملکردی می‌شود. دلیل آن این است که هسته قبل از ادامه یک برنامه‌ی متوقف شده، باید تمام رجیسترها را به حالت اولیه خود برگرداند. این بدان معناست که هسته در هر فراخوانی سیستم یا وقفه سخت افزاری باید حالت کامل SIMD را در حافظه اصلی ذخیره کند. از آنجا که حالت SIMD بسیار بزرگ است (512-1600 بایت) و وقفه‌ها ممکن است اغلب اتفاق بیفتند، این عملیات ذخیره و بازیابی اضافی به طور قابل ملاحظه‌ای به عملکرد آسیب می‌رساند. برای جلوگیری از این، SIMD را برای هسته خود غیرفعال می‌کنیم (نه برای برنامه‌هایی که از روی آن اجرا می شوند!).
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
یک مشکل در غیرفعال کردن SIMD این است که عملیات‌های مُمَیزِ شناور (ترجمه: floating point) در `x86_64` به طور پیش فرض به ثبات‌های SIMD نیاز دارد. برای حل این مشکل، ویژگی `soft-float` را اضافه می‌کنیم، که از طریق عملکردهای نرم‌افزاری مبتنی بر اعداد صحیح عادی، تمام عملیات مُمَیزِ شناور را شبیه‌سازی می‌کند.
For more information, see our post on [disabling SIMD](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.md).
#### کنار هم قرار دادن
فایل مشخصات هدف ما اکنون به این شکل است:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### ساخت هسته
عملیات کامپایل کردن برای هدف جدید ما از قراردادهای لینوکس استفاده خواهد کرد (کاملاً مطمئن نیستم که چرا، تصور می‌کنم این فقط پیش فرض LLVM باشد). این بدان معنی است که ما به یک نقطه ورود به نام `start_` نیاز داریم همانطور که در [پست قبلی] توضیح داده شد:
[پست قبلی]: @/edition-2/posts/01-freestanding-rust-binary/index.md
```rust
// src/main.rs
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
```
توجه داشته باشید که بدون توجه به سیستم عامل میزبان، باید نقطه ورود را `start_` بنامید.
اکنون می‌توانیم با نوشتن نام فایل JSON بعنوان `target--`، هسته خود را برای هدف جدید بسازیم:
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
شکست میخورد! این خطا به ما می‌گوید که کامپایلر Rust دیگر [کتابخانه `core`] را پیدا نمی‌کند. این کتابخانه شامل انواع اساسی Rust مانند `Result` ، `Option` و iterators است، و به طور ضمنی به همه کریت‌های `no_std` لینک است.
[کتابخانه `core`]: https://doc.rust-lang.org/nightly/core/index.html
مشکل این است که کتابخانه core همراه با کامپایلر Rust به عنوان یک کتابخانه _precompiled_ (ترجمه: از پیش کامپایل شده) توزیع می‌شود. بنابراین فقط برای میزبان‌های سه‌گانه پشتیبانی شده مجاز است (مثلا، `x86_64-unknown-linux-gnu`) اما برای هدف سفارشی ما صدق نمی‌کند. اگر می‌خواهیم برای سیستم‌های هدف دیگر کدی را کامپایل کنیم، ابتدا باید `core` را برای این اهداف دوباره کامپایل کنیم.
#### آپشن `build-std`
این‌جاست که [ویژگی `build-std`] کارگو وارد می‌شود. این امکان را می‌دهد تا بجای استفاده از نسخه‌های از پیش کامپایل شده با نصب Rust، بتوانیم `core` و ‌کریت سایر کتابخانه‌های استاندارد را در صورت نیاز دوباره کامپایل کنیم. این ویژگی بسیار جدید بوده و هنوز تکمیل نشده است، بنابراین بعنوان «ناپایدار» علامت گذاری شده و فقط در [نسخه شبانه کامپایلر Rust] در دسترس می‌باشد.
[ویژگی `build-std`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[نسخه شبانه کامپایلر Rust]: #installing-rust-nightly
برای استفاده از این ویژگی، ما نیاز داریم تا یک فایل [پیکربندی کارگو] در `cargo/config.toml.` با محتوای زیر بسازیم:
```toml
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
```
این به کارگو می‌گوید که باید `core` و کتابخانه‌ `compiler_builtins` را دوباره کامپایل کند. مورد دوم لازم است زیرا یک وابستگی از `core` است. به منظور کامپایل مجدد این کتابخانه‌ها، کارگو نیاز به دسترسی به کد منبع Rust دارد که می‌توانیم آن را با `rustup component add rust-src` نصب کنیم.
<div class="note">
**یادداشت:** کلید پیکربندی `unstable.build-std` به نسخه‌‌ای جدیدتر از نسخه 2020-07-15 شبانه Rust نیاز دارد.
</div>
پس از تنظیم کلید پیکربندی `unstable.build-std` و نصب مولفه `rust-src`، می‌توانیم مجددا دستور بیلد (کلمه: build) را اجرا کنیم.
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
می‌بینیم که `cargo build` دوباره `core` و `rustc-std-workspace-core` (یک وابستگی از `compiler_builtins`)، و کتابخانه `compiler_builtins` را برای سیستم هدف سفارشی‌مان کامپایل می‌کند.
#### موارد ذاتیِ مربوط به مموری
کامپایلر Rust فرض می‌کند که مجموعه خاصی از توابع داخلی برای همه سیستم‌ها در دسترس است. اکثر این توابع توسط کریت `compiler_builtins` ارائه می‌شود که ما آن را به تازگی مجددا کامپایل کردیم. با این حال‌، برخی از توابع مربوط به حافظه در آن کریت وجود دارد که به طور پیش‌فرض فعال نیستند زیرا به طور معمول توسط کتابخانه C موجود در سیستم ارائه می‌شوند. این توابع شامل `memset` می‌باشد که مجموعه تمام بایت‌ها را در یک بلوک حافظه بر روی یک مقدار مشخص قرار می‌دهد، `memcpy` که یک بلوک حافظه را در دیگری کپی می‌کند و `memcmp` که دو بلوک حافظه را با یکدیگر مقایسه می‌کند. اگرچه ما در حال حاضر به هیچ یک از این توابع برای کامپایل هسته خود نیازی نداریم، اما به محض افزودن کدهای بیشتر به آن، این توابع مورد نیاز خواهند بود (برای مثال، هنگام کپی کردن یک ساختمان).
از آنجا که نمی‌توانیم به کتابخانه C سیستم عامل لینک دهیم، به روشی جایگزین برای ارائه این توابع به کامپایلر نیاز داریم. یک رویکرد ممکن برای این کار می‌تواند پیاده‌سازی توابع `memset` و غیره و اعمال صفت `[no_mangle]#` (برای جلوگیری از تغییر نام خودکار در هنگام کامپایل کردن) بر روی آنها اعمال باشد. با این حال، این خطرناک است زیرا کوچک‌ترین اشتباهی در اجرای این توابع می‌تواند منجر به یک رفتار تعریف نشده شود. به عنوان مثال، ممکن است هنگام پیاده‌سازی `memcpy` با استفاده از حلقه `for` یک بازگشت بی‌پایان داشته باشید زیرا حلقه‌های `for` به طور ضمنی مِتُد تریتِ (کلمه: trait) [`IntoIterator::into_iter`] را فراخوانی می‌کنند، که ممکن است دوباره `memcpy` را فراخوانی کند. بنابراین بهتر است به جای آن از پیاده سازی‌های تست شده موجود، مجدداً استفاده کنید.
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
خوشبختانه کریت `compiler_builtins` از قبل شامل پیاده سازی تمام توابع مورد نیازمان است، آن‌ها فقط به طور پیش فرض غیرفعال هستند تا با پیاده سازی های کتابخانه C تداخلی نداشته باشند. ما می‌توانیم آنها را با تنظیم پرچم [`build-std-features`] کارگو بر روی `["compiler-builtins-mem"]` فعال کنیم. مانند پرچم `build-std`، این پرچم می‌تواند به عنوان پرچم `Z-` در خط فرمان استفاده شود یا در جدول `unstable` در فایل `cargo/config.toml.` پیکربندی شود. از آن‌جا که همیشه می‌خواهیم با این پرچم بیلد کنیم، گزینه پیکربندی فایل منطقی‌تر است:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# in .cargo/config.toml
[unstable]
build-std-features = ["compiler-builtins-mem"]
```
پشتیبانی برای ویژگی `compiler-builtins-mem` [به تازگی اضافه شده](https://github.com/rust-lang/rust/pull/77284)، پس حداقل به نسخه‌ شبانه‌ `2020-09-30` نیاز دارید.
در پشت صحنه، این پرچم [ویژگی `mem`] از کریت `compiler_builtins` را فعال می‌کند. اثرش این است که صفت `[no_mangle]#` بر روی [پیاده‌سازی `memcpy` و بقیه موارد] از کریت اعمال می‌شود، که آن‌ها در دسترس لینکر قرار می‌دهد. شایان ذکر است که این توابع در حال حاضر [بهینه نشده‌اند]، بنابراین ممکن است عملکرد آ‌ن‌ها در بهترین حالت نباشد، اما حداقل صحیح هستند. برای `x86_64` ، یک pull request باز برای [بهینه سازی این توابع با استفاده از دستورالعمل‌های خاص اسمبلی][memcpy rep movsb] وجود دارد.
[ویژگی `mem`]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L51-L52
[پیاده‌سازی `memcpy` و بقیه موارد]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
[بهینه نشده‌اند]: https://github.com/rust-lang/compiler-builtins/issues/339
[memcpy rep movsb]: https://github.com/rust-lang/compiler-builtins/pull/365
با این تغییر، هسته ما برای همه توابع مورد نیاز کامپایلر، پیاده سازی معتبری دارد، بنابراین حتی اگر کد ما پیچیده‌تر باشد نیز باز کامپایل می‌شود.
#### تنظیم یک هدف پیش‌ فرض
برای این‌که نیاز نباشد در هر فراخوانی `cargo build` پارامتر `target--` را وارد کنیم، می‌توانیم هدف پیش‌فرض را بازنویسی کنیم. برای این کار، ما کد زیر را به [پیکربندی کارگو] در فایل `cargo/config.toml.` اضافه می‌کنیم:
[پیکربندی کارگو]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# in .cargo/config.toml
[build]
target = "x86_64-blog_os.json"
```
این به `cargo` می‌گوید در صورتی که صریحاً از `target--` استفاده نکردیم، از هدف ما یعنی `x86_64-blog_os.json` استفاده کند. در واقع اکنون می‌توانیم هسته خود را با یک `cargo build` ساده بسازیم. برای اطلاعات بیشتر در مورد گزینه‌های پیکربندی کارگو، [اسناد رسمی][پیکربندی کارگو] را بررسی کنید.
اکنون می‌توانیم هسته را برای یک هدف bare metal با یک `cargo build` ساده بسازیم. با این حال، نقطه ورود `start_` ما، که توسط بوت لودر فراخوانی می‌شود، هنوز خالی است. وقت آن است که از طریق آن، چیزی را در خروجی نمایش دهیم.
### چاپ روی صفحه
ساده‌ترین راه برای چاپ متن در صفحه در این مرحله [بافر متن VGA] است. این یک منطقه خاص حافظه است که به سخت افزار VGA نگاشت (مَپ) شده و حاوی مطالب نمایش داده شده روی صفحه است. به طور معمول از 25 خط تشکیل شده است که هر کدام شامل 80 سلول کاراکتر هستند. هر سلول کاراکتر یک کاراکتر ASCII را با برخی از رنگ‌های پیش زمینه و پس زمینه نشان می‌دهد. خروجی صفحه به این شکل است:
[بافر متن VGA]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
![screen output for common ASCII characters](https://upload.wikimedia.org/wikipedia/commons/f/f8/Codepage-437.png)
ما در پست بعدی، جایی که اولین درایور کوچک را برای آن می‌نویسیم، در مورد قالب دقیق بافر متن VGA بحث خواهیم کرد. برای چاپ “!Hello World”، فقط باید بدانیم که بافر در آدرس `0xb8000` قرار دارد و هر سلول کاراکتر از یک بایت ASCII و یک بایت رنگ تشکیل شده است.
پیاده‌سازی مشابه این است:
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
ابتدا عدد صحیح `0xb8000` را در یک اشاره‌گر خام (ترجمه: raw pointer) می‌ریزیم. سپس روی بایت‌های [رشته بایت][byte string] [استاتیک][static] `HELLO` [پیمایش][iterate] می‌کنیم. ما از متد [`enumerate`] برای اضافه کردن متغیر درحال اجرای `i` استفاده می‌کنیم. در بدنه حلقه for، از متد [`offset`] برای نوشتن بایت رشته و بایت رنگ مربوطه استفاده می‌کنیم (`0xb` فیروزه‌ای روشن است).
[iterate]: https://doc.rust-lang.org/stable/book/ch13-02-iterators.html
[static]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
[byte string]: https://doc.rust-lang.org/reference/tokens.html#byte-string-literals
[اشاره‌گر خام]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
توجه داشته باشید که یک بلوک [`unsafe`] همیشه هنگام نوشتن در حافظه مورد استفاده قرار می‌گیرد. دلیل این امر این است که کامپایلر Rust نمی‌تواند معتبر بودن اشاره‌گرهای خام که ایجاد میکنیم را ثابت کند. آن‌ها می‌توانند به هر کجا اشاره کنند و منجر به خراب شدن داده‌ها شوند. با قرار دادن آن‌ها در یک بلوک `unsafe`، ما در اصل به کامپایلر می‌گوییم که کاملاً از معتبر بودن عملیات اطمینان داریم. توجه داشته باشید که یک بلوک `unsafe`، بررسی‌های ایمنی Rust را خاموش نمی‌کند. فقط به شما این امکان را می‌دهد که [پنج کار اضافی] انجام دهید.
[`unsafe`]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html
[پنج کار اضافی]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#unsafe-superpowers
می خواهم تأکید کنم که **این روشی نیست که ما بخواهیم در Rust کارها را از طریق آن پبش ببریم!** به هم ریختگی هنگام کار با اشاره‌گرهای خام در داخل بلوک‌های ناامن بسیار محتمل و ساده است، به عنوان مثال، اگر مواظب نباشیم به راحتی می‌توانیم فراتر از انتهای بافر بنویسیم.
بنابراین ما می‌خواهیم تا آن‌جا که ممکن است استفاده از `unsafe` را به حداقل برسانیم. Rust با ایجاد انتزاع‌های ایمن به ما توانایی انجام این کار را می‌دهد. به عنوان مثال، ما می‌توانیم یک نوع بافر VGA ایجاد کنیم که تمام کدهای ناامن را در خود قرار داده و اطمینان حاصل کند که انجام هرگونه اشتباه از خارج از این انتزاع _غیرممکن_ است. به این ترتیب، ما فقط به حداقل مقادیر ناامن نیاز خواهیم داشت و می‌توان اطمینان داشت که [ایمنی حافظه] را نقض نمی‌کنیم. در پست بعدی چنین انتزاع ایمن بافر VGA را ایجاد خواهیم کرد.
[ایمنی حافظه]: https://en.wikipedia.org/wiki/Memory_safety
## اجرای هسته
حال یک هسته اجرایی داریم که کار محسوسی را انجام می‌دهد، پس زمان اجرای آن فرا رسیده است. ابتدا، باید هسته کامپایل شده خود را با پیوند دادن آن به یک بوت‌لودر، به یک دیسک ایمیج قابل بوت تبدیل کنیم. سپس می‌توانیم دیسک ایمیج را در ماشین مجازی [QEMU] اجرا یا با استفاده از یک درایو USB آن را بر روی سخت افزار واقعی بوت کنیم.
### ساخت دیسک ایمیج
برای تبدیل هسته کامپایل شده به یک دیسک ایمیج قابل بوت، باید آن را با یک بوت لودر پیوند دهیم. همانطور که در [بخش مربوط به بوت شدن (لینک باید اپدیت شود)] آموختیم، بوت لودر مسئول مقداردهی اولیه پردازنده و بارگیری هسته می‌باشد.
[بخش مربوط به بوت شدن]: #the-boot-process
به جای نوشتن یک بوت لودر مخصوص خودمان، که به تنهایی یک پروژه است، از کریت [`bootloader`] استفاده می‌کنیم. این کریت بوت‌لودر اصلی BIOS را بدون هیچگونه وابستگی به C، فقط با استفاده از Rust و این‌لاین اسمبلی پیاده سازی می‌کند. برای استفاده از آن برای راه اندازی هسته، باید وابستگی به آن را ضافه کنیم:
[`bootloader`]: https://crates.io/crates/bootloader
```toml
# in Cargo.toml
[dependencies]
bootloader = "0.9.23"
```
افزودن بوت‌لودر به عنوان وابستگی برای ایجاد یک دیسک ایمیج قابل بوت کافی نیست. مشکل این است که ما باید هسته خود را با بوت لودر پیوند دهیم، اما کارگو از [اسکریپت های بعد از بیلد] پشتیبانی نمی‌کند.
[اسکریپت های بعد از بیلد]: https://github.com/rust-lang/cargo/issues/545
برای حل این مشکل، ما ابزاری به نام `bootimage` ایجاد کردیم که ابتدا هسته و بوت لودر را کامپایل می‌کند و سپس آن‌ها را به یکدیگر پیوند می‌دهد تا یک ایمیج دیسک قابل بوت ایجاد کند. برای نصب ابزار‌، دستور زیر را در ترمینال خود اجرا کنید:
```
cargo install bootimage
```
برای اجرای `bootimage` و ساختن بوت‌لودر، شما باید `llvm-tools-preview` که یک مولفه rustup می‌باشد را نصب داشته باشید. شما می‌توانید این کار را با اجرای دستور `rustup component add llvm-tools-preview` انجام دهید.
پس از نصب `bootimage` و اضافه کردن مولفه `llvm-tools-preview`، ما می‌توانیم یک دیسک ایمیج قابل بوت را با اجرای این دستور ایجاد کنیم:
```
> cargo bootimage
```
می‌بینیم که این ابزار، هسته ما را با استفاده از `cargo build` دوباره کامپایل می‌کند، بنابراین به طور خودکار هر تغییری که ایجاد می‌کنید را در‌بر‌ میگیرد. پس از آن بوت‌لودر را کامپایل می‌کند که ممکن است مدتی طول بکشد. مانند تمام کریت‌های وابسته ، فقط یک بار بیلد می‌شود و سپس کش (کلمه: cache) می‌شود، بنابراین بیلدهای بعدی بسیار سریع‌تر خواهد بود. سرانجام، `bootimage`، بوت‌لودر و هسته شما را با یک دیسک ایمیج قابل بوت ترکیب می‌کند.
پس از اجرای این دستور، شما باید یک دیسک ایمیج قابل بوت به نام `bootimage-blog_os.bin` در مسیر `target/x86_64-blog_os/debug` ببینید. شما می‌توانید آن را در یک ماشین مجازی بوت کنید یا آن را در یک درایو USB کپی کرده و روی یک سخت افزار واقعی بوت کنید. (توجه داشته باشید که این یک ایمیج CD نیست، بنابراین رایت کردن آن روی CD بی‌فایده‌ است چرا که ایمیج CD دارای قالب متفاوتی است).
#### چگونه کار می کند؟
ابزار `bootimage` مراحل زیر را در پشت صحنه انجام می دهد:
- کرنل ما را به یک فایل [ELF] کامپایل می‌کند.
- وابستگی بوت‌لودر را به عنوان یک اجرایی مستقل (ترجمه: standalone executable) کامپایل می‌کند.
- بایت‌های فایل ELF هسته را به بوت‌لودر پیوند می‌دهد.
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[rust-osdev/bootloader]: https://github.com/rust-osdev/bootloader
وقتی بوت شد، بوت‌لودر فایل ضمیمه شده ELF را خوانده و تجزیه می‌کند. سپس بخش‌های (ترجمه: segments) برنامه را به آدرس‌های مجازی در جداول صفحه نگاشت (مپ) می‌کند، بخش `bss.` را صفر کرده و یک پشته را تنظیم می‌کند. در آخر، آدرس نقطه ورود (تابع `start_`) را خوانده و به آن پرش میکند.
### بوت کردن در QEMU
اکنون می‌توانیم دیسک ایمیج را در یک ماشین مجازی بوت کنیم. برای راه اندازی آن در [QEMU]، دستور زیر را اجرا کنید:
[QEMU]: https://www.qemu.org/
```
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]
```
این یک پنجره جداگانه با این شکل باز می‌کند:
![QEMU showing "Hello World!"](qemu.png)
می‌بینیم که “!Hello World” بر روی صفحه قابل مشاهده است.
### ماشین واقعی
همچنین می‌توانید آن را بر روی یک درایو USB رایت و بر روی یک دستگاه واقعی بوت کنید:
```
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
در اینجا `sdX` نام دستگاه USB شماست. **مراقب باشید** که نام دستگاه را به درستی انتخاب کنید، زیرا همه داده‌های موجود در آن دستگاه بازنویسی می‌شوند.
پس از رایت کردن ایمیج در USB، می‌توانید با بوت کردن، آن را بر روی سخت افزار واقعی اجرا کنید. برای راه اندازی از طریق USB احتمالاً باید از یک منوی بوت ویژه استفاده کنید یا ترتیب بوت را در پیکربندی BIOS تغییر دهید. توجه داشته باشید که این در حال حاضر برای دستگاه‌های UEFI کار نمی‌کند، زیرا کریت `bootloader` هنوز پشتیبانی UEFI را ندارد.
### استفاده از `cargo run`
برای سهولت اجرای هسته در QEMU، می‌توانیم کلید پیکربندی `runner` را برای کارگو تنظیم کنیم:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
جدول `'target.'cfg(target_os = "none")` برای همه اهدافی که فیلد `"os"` فایل پیکربندی هدف خود را روی `"none"` تنظیم کرده‌اند، اعمال می‌شود. این شامل هدف `x86_64-blog_os.json` نیز می‌شود. `runner` دستوری را که باید برای `cargo run` فراخوانی شود مشخص می‌کند. دستور پس از بیلد موفقیت آمیز با مسیر فایل اجرایی که به عنوان اولین آرگومان داده شده، اجرا می‌شود. برای جزئیات بیشتر به [اسناد کارگو][پیکربندی کارگو] مراجعه کنید.
دستور `bootimage runner` بصورت مشخص طراحی شده تا بعنوان یک `runner` قابل اجرا مورد استفاده قرار بگیرد. فایل اجرایی داده شده را به بوت‌لودر پروژه پیوند داده و سپس QEMU را اجرا می‌کند. برای جزئیات بیشتر و گزینه‌های پیکربندی احتمالی‌، به [توضیحات `bootimage`] مراجعه کنید.
[توضیحات `bootimage`]: https://github.com/rust-osdev/bootimage
اکنون می‌توانیم از `cargo run` برای کامپایل هسته خود و راه اندازی آن در QEMU استفاده کنیم.
## مرحله بعد چیست؟
در پست بعدی، ما بافر متن VGA را با جزئیات بیشتری بررسی خواهیم کرد و یک رابط امن برای آن می‌نویسیم. همچنین پشتیبانی از ماکرو `println` را نیز اضافه خواهیم کرد.

View File

@@ -1,500 +0,0 @@
+++
title = "Un noyau Rust minimal"
weight = 2
path = "fr/minimal-rust-kernel"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "c689ecf810f8e93f6b2fb3c4e1e8b89b8a0998eb"
# GitHub usernames of the people that translated this post
translators = ["TheMimiCodes", "maximevaillancourt"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["alaincao"]
+++
Dans cet article, nous créons un noyau Rust 64-bit minimal pour l'architecture x86. Nous continuons le travail fait dans l'article précédent “[Un binaire Rust autonome][freestanding Rust binary]” pour créer une image de disque amorçable qui affiche quelque chose à l'écran.
[freestanding Rust binary]: @/edition-2/posts/01-freestanding-rust-binary/index.fr.md
<!-- more -->
Cet article est développé de manière ouverte sur [GitHub]. Si vous avez des problèmes ou des questions, veuillez ouvrir une _Issue_ sur GitHub. Vous pouvez aussi laisser un commentaire [au bas de la page]. Le code source complet pour cet article se trouve dans la branche [`post-02`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[au bas de la page]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## Le processus d'amorçage
Quand vous allumez un ordinateur, il commence par exécuter le code du micrologiciel qui est enregistré dans la carte mère ([ROM]). Ce code performe un [test d'auto-diagnostic de démarrage][power-on self-test], détecte la mémoire volatile disponible, et pré-initialise le processeur et le matériel. Par la suite, il recherche un disque amorçable et commence le processus d'amorçage du noyau du système d'exploitation.
[ROM]: https://fr.wikipedia.org/wiki/M%C3%A9moire_morte
[power-on self-test]: https://fr.wikipedia.org/wiki/Power-on_self-test_(informatique)
Sur x86, il existe deux standards pour les micrologiciels : le “Basic Input/Output System“ (**[BIOS]**) et le nouvel “Unified Extensible Firmware Interface” (**[UEFI]**). Le BIOS standard est vieux et dépassé, mais il est simple et bien supporté sur toutes les machines x86 depuis les années 1980. Au contraire, l'UEFI est moderne et offre davantage de fonctionnalités. Cependant, il est plus complexe à installer (du moins, selon moi).
[BIOS]: https://fr.wikipedia.org/wiki/BIOS_(informatique)
[UEFI]: https://fr.wikipedia.org/wiki/UEFI
Actuellement, nous offrons seulement un support BIOS, mais nous planifions aussi du support pour l'UEFI. Si vous aimeriez nous aider avec cela, consultez l'[_issue_ sur GitHub](https://github.com/phil-opp/blog_os/issues/349).
### Amorçage BIOS
Presque tous les systèmes x86 peuvent amorcer le BIOS, y compris les nouvelles machines UEFI qui utilisent un BIOS émulé. C'est une bonne chose car cela permet d'utiliser la même logique d'amorçage sur toutes les machines du dernier siècle. Cependant, cette grande compatibilité est aussi le plus grand inconvénient de l'amorçage BIOS, car cela signifie que le CPU est mis dans un mode de compatibilité 16-bit appelé _[real mode]_ avant l'amorçage afin que les bootloaders archaïques des années 1980 puissent encore fonctionner.
Mais commençons par le commencement :
Quand vous allumez votre ordinateur, il charge le BIOS provenant d'un emplacement de mémoire flash spéciale localisée sur la carte mère. Le BIOS exécute des tests d'auto-diagnostic et des routines d'initialisation du matériel, puis il cherche des disques amorçables. S'il en trouve un, le contrôle est transféré à son _bootloader_, qui est une portion de 512 octets de code exécutable enregistré au début du disque. Vu que la plupart des bootloaders dépassent 512 octets, ils sont généralement divisés en deux phases: la première, plus petite, tient dans ces 512 octets, tandis que la seconde phase est chargée subséquemment.
Le bootloader doit déterminer l'emplacement de l'image de noyau sur le disque et la charger en mémoire. Il doit aussi passer le processeur de 16-bit ([real mode]) à 32-bit ([protected mode]), puis à 64-bit ([long mode]), dans lequel les registres 64-bit et la totalité de la mémoire principale sont disponibles. Sa troisième responsabilité est de récupérer certaines informations (telle que les associations mémoires) du BIOS et de les transférer au noyau du système d'exploitation.
[real mode]: https://fr.wikipedia.org/wiki/Mode_r%C3%A9el
[protected mode]: https://fr.wikipedia.org/wiki/Mode_prot%C3%A9g%C3%A9
[long mode]: https://en.wikipedia.org/wiki/Long_mode
[memory segmentation]: https://fr.wikipedia.org/wiki/Segmentation_(informatique)
Implémenter un bootloader est fastidieux car cela requiert l'écriture en language assembleur ainsi que plusieurs autres étapes particulières comme “écrire une valeur magique dans un registre du processeur". Par conséquent, nous ne couvrons pas la création d'un bootloader dans cet article et fournissons plutôt un outil appelé [bootimage] qui ajoute automatiquement un bootloader au noyau.
[bootimage]: https://github.com/rust-osdev/bootimage
Si vous êtes intéressé par la création de votre propre booloader : restez dans le coin, plusieurs articles sur ce sujet sont déjà prévus à ce sujet! <!-- , jetez un coup d'oeil à nos articles “_[Writing a Bootloader]_”, où nous expliquons en détails comment écrire un bootloader. -->
#### Le standard Multiboot
Pour éviter que chaque système d'exploitation implémente son propre bootloader, qui est seulement compatible avec un seul système d'exploitation, la [Free Software Foundation] a créé en 1995 un bootloader standard public appelé [Multiboot]. Le standard définit une interface entre le bootloader et le système d'exploitation afin que n'importe quel bootloader compatible Multiboot puisse charger n'importe quel système d'exploitation compatible Multiboot. L'implémentation de référence est [GNU GRUB], qui est le bootloader le plus populaire pour les systèmes Linux.
[Free Software Foundation]: https://fr.wikipedia.org/wiki/Free_Software_Foundation
[Multiboot]: https://wiki.osdev.org/Multiboot
[GNU GRUB]: https://fr.wikipedia.org/wiki/GNU_GRUB
Pour créer un noyau compatible Multiboot, il suffit d'insérer une [en-tête Multiboot][Multiboot header] au début du fichier du noyau. Cela rend très simple l'amorçage d'un système d'exploitation depuis GRUB. Cependant, GRUB et le standard Multiboot présentent aussi quelques problèmes :
[Multiboot header]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format
- Ils supportent seulement le "protected mode" 32-bit. Cela signifie que vous devez encore effectuer la configuration du processeur pour passer au "long mode" 64-bit.
- Ils sont conçus pour simplifier le bootloader plutôt que le noyau. Par exemple, le noyau doit être lié avec une [taille de page prédéfinie][adjusted default page size], étant donné que GRUB ne peut pas trouver les entêtes Multiboot autrement. Un autre exemple est que l'[information de boot][boot information], qui est fournies au noyau, contient plusieurs structures spécifiques à l'architecture au lieu de fournir des abstractions pures.
- GRUB et le standard Multiboot sont peu documentés.
- GRUB doit être installé sur un système hôte pour créer une image de disque amorçable depuis le fichier du noyau. Cela rend le développement sur Windows ou sur Mac plus difficile.
[adjusted default page size]: https://wiki.osdev.org/Multiboot#Multiboot_2
[boot information]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format
En raison de ces désavantages, nous avons décidé de ne pas utiliser GRUB ou le standard Multiboot. Cependant, nous avons l'intention d'ajouter le support Multiboot à notre outil [bootimage], afin qu'il soit aussi possible de charger le noyau sur un système GRUB. Si vous êtes interessé par l'écriture d'un noyau Multiboot conforme, consultez la [première édition][first edition] de cette série d'articles.
[first edition]: @/edition-1/_index.md
### UEFI
(Nous ne fournissons pas le support UEFI à l'heure actuelle, mais nous aimerions bien! Si vous voulez aider, dites-le nous dans cette [_issue_ GitHub](https://github.com/phil-opp/blog_os/issues/349).)
## Un noyau minimal
Maintenant que nous savons à peu près comment un ordinateur démarre, il est temps de créer notre propre noyau minimal. Notre objectif est de créer une image de disque qui affiche “Hello World!” à l'écran lorsqu'il démarre. Nous ferons ceci en améliorant le [binaire Rust autonome][freestanding Rust binary] du dernier article.
Comme vous vous en rappelez peut-être, nous avons créé un binaire autonome grâce à `cargo`, mais selon le système d'exploitation, nous avions besoin de différents points d'entrée et d'options de compilation. C'est dû au fait que `cargo` construit pour _système hôte_ par défaut, c'est-à-dire le système que vous utilisez. Ce n'est pas ce que nous voulons pour notre noyau, car un noyau qui s'exécute, par exemple, sur Windows n'a pas de sens. Nous voulons plutôt compiler pour un _système cible_ bien défini.
### Installer une version nocturne de Rust
Rust a trois canaux de distribution : _stable_, _beta_, et _nightly_. Le Livre de Rust explique bien les différences entre ces canaux, alors prenez une minute et [jetez y un coup d'oeil](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html#choo-choo-release-channels-and-riding-the-trains). Pour construire un système d'exploitation, nous aurons besoin de fonctionalités expérimentales qui sont disponibles uniquement sur le canal de distribution nocturne. Donc nous devons installer une version nocturne de Rust.
Pour gérer l'installation de Rust, je recommande fortement [rustup]. Il vous permet d'installer les versions nocturne, beta et stable du compilateur côte-à-côte et facilite leurs mises à jour. Avec rustup, vous pouvez utiliser un canal de distribution nocturne pour le dossier actuel en exécutant `rustup override set nightly`. Par ailleurs, vous pouvez ajouter un fichier appelé `rust-toolchain` avec le contenu `nightly` au dossier racine du projet. Vous pouvez vérifier que vous avez une version nocturne installée en exécutant `rustc --version`: Le numéro de la version devrait comprendre `-nightly` à la fin.
[rustup]: https://www.rustup.rs/
La version nocturne du compilateur nous permet d'activer certaines fonctionnalités expérimentales en utilisant certains _drapeaux de fonctionalité_ dans le haut de notre fichier. Par exemple, nous pourrions activer [macro expérimentale `asm!`][`asm!` macro] pour écrire du code assembleur intégré en ajoutant `#![feature(asm)]` au haut de notre `main.rs`. Notez que ces fonctionnalités expérimentales sont tout à fait instables, ce qui veut dire que des versions futures de Rust pourraient les changer ou les retirer sans préavis. Pour cette raison, nous les utiliserons seulement lorsque strictement nécessaire.
[`asm!` macro]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### Spécification de la cible
Cargo supporte différent systèmes cibles avec le paramètre `--target`. La cible est définie par un soi-disant _[triplet de cible][target triple]_, qui décrit l'architecteur du processeur, le fabricant, le système d'exploitation, et l'interface binaire d'application ([ABI]). Par exemple, le triplet `x86_64-unknown-linux-gnu` décrit un système avec un processeur `x86_64`, sans fabricant défini, et un système d'exploitation Linux avec l'interface binaire d'application GNU. Rust supporte [plusieurs différents triplets de cible][platform-support], incluant `arm-linux-androideabi` pour Android ou [`wasm32-unknown-unknown` pour WebAssembly](https://www.hellorust.com/setup/wasm-target/).
[target triple]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
[ABI]: https://stackoverflow.com/a/2456882
[platform-support]: https://forge.rust-lang.org/release/platform-support.html
[custom-targets]: https://doc.rust-lang.org/nightly/rustc/targets/custom.html
Pour notre système cible toutefois, nous avons besoin de paramètres de configuration spéciaux (par exemple, pas de système d'explotation sous-jacent), donc aucun des [triplets de cible existants][platform-support] ne convient. Heureusement, Rust nous permet de définir [notre propre cible][custom-targets] par l'entremise d'un fichier JSON. Par exemple, un fichier JSON qui décrit une cible `x86_64-unknown-linux-gnu` ressemble à ceci:
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
La plupart des champs sont requis par LLVM pour générer le code pour cette plateforme. Par exemple, le champ [`data-layout`] définit la taille de divers types d'entiers, de nombres à virgule flottante, et de pointeurs. Puis, il y a des champs que Rust utilise pour de la compilation conditionelle, comme `target-pointer-width`. Le troisième type de champ définit comment la crate doit être construite. Par exemple, le champ `pre-link-args` spécifie les arguments fournis au [lieur][linker].
[`data-layout`]: https://llvm.org/docs/LangRef.html#data-layout
[linker]: https://en.wikipedia.org/wiki/Linker_(computing)
Nous pouvons aussi cibler les systèmes `x86_64` avec notre noyau, donc notre spécification de cible ressemblera beaucoup à celle plus haut. Commençons par créer un fichier `x86_64-blog_os.json` (utilisez le nom de votre choix) avec ce contenu commun:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
Notez que nous avons changé le système d'exploitation dans le champs `llvm-target` et `os` en `none`, puisque nous ferons l'exécution sur du "bare metal" (donc, sans système d'exploitation sous-jacent).
Nous ajoutons ensuite les champs suivants reliés à la construction:
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
Plutôt que d'utiliser le lieur par défaut de la plateforme (qui pourrait ne pas supporter les cibles Linux), nous utilisons le lieur multi-plateforme [LLD] qui est inclut avec Rust pour lier notre noyau.
[LLD]: https://lld.llvm.org/
```json
"panic-strategy": "abort",
```
Ce paramètre spécifie que la cible ne permet pas le [déroulement de la pile][stack unwinding] lorsque le noyau panique, alors le système devrait plutôt s'arrêter directement. Ceci mène au même résultat que l'option `panic = "abort"` dans notre Cargo.toml, alors nous pouvons la retirer de ce fichier. (Notez que, contrairement à l'option Cargo.toml, cette option de cible s'applique aussi quand nous recompilerons la bibliothèque `core` plus loin dans cet article. Ainsi, même si vous préférez garder l'option Cargo.toml, gardez cette option.)
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
```json
"disable-redzone": true,
```
Nous écrivons un noyau, donc nous devrons éventuellement gérer les interruptions. Pour ce faire en toute sécurité, nous devons désactiver une optimisation de pointeur de pile nommée la _“zone rouge"_, puisqu'elle causerait une corruption de la pile autrement. Pour plus d'informations, lire notre article séparé à propos de la [désactivation de la zone rouge][disabling the red zone].
[disabling the red zone]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.md
```json
"features": "-mmx,-sse,+soft-float",
```
Le champ `features` active/désactive des fonctionalités de la cible. Nous désactivons les fonctionalités `mmx` et `sse` en les précédant d'un signe "moins" et activons la fonctionnalité `soft-float` en la précédant d'un signe "plus". Notez qu'il ne doit pas y avoir d'espace entre les différentes fonctionnalités, sinon LLVM n'arrive pas à analyser la chaîne de caractères des fonctionnalités.
Les fonctionnalités `mmx` et `sse` déterminent le support les instructions [Single Instruction Multiple Data (SIMD)], qui peuvent souvent significativement accélérer les programmes. Toutefois, utiliser les grands registres SIMD dans les noyaux des systèmes d'exploitation mène à des problèmes de performance. Ceci parce que le noyau a besoin de restaurer tous les registres à leur état original avant de continuer un programme interrompu. Cela signifie que le noyau doit enregistrer l'état SIMD complet dans la mémoire principale à chaque appel système ou interruption matérielle. Puisque l'état SIMD est très grand (5121600 octets) et que les interruptions peuvent survenir très fréquemment, ces opérations d'enregistrement/restauration additionnelles nuisent considérablement à la performance. Pour prévenir cela, nous désactivons SIMD pour notre noyau (pas pour les applications qui s'exécutent dessus!).
[Single Instruction Multiple Data (SIMD)]: https://fr.wikipedia.org/wiki/Single_instruction_multiple_data
Un problème avec la désactivation de SIMD est que les opérations sur les nombres à virgule flottante sur `x86_64` nécessitent les registres SIMD par défaut. Pour résoudre ce problème, nous ajoutons la fonctionnalité `soft-float`, qui émule toutes les opérations à virgule flottante avec des fonctions logicielles utilisant des entiers normaux.
Pour plus d'informations, voir notre article sur la [désactivation de SIMD](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.md).
#### Assembler le tout
Notre fichier de spécification de cible ressemble maintenant à ceci :
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### Construction de notre noyau
Compiler pour notre nouvelle cible utilisera les conventions Linux (je ne suis pas trop certain pourquoi; j'assume que c'est simplement le comportement par défaut de LLVM). Cela signifie que nos avons besoin d'un point d'entrée nommé `_start` comme décrit dans [l'article précédent][previous post]:
[previous post]: @/edition-2/posts/01-freestanding-rust-binary/index.fr.md
```rust
// src/main.rs
#![no_std] // ne pas lier la bibliothèque standard Rust
#![no_main] // désactiver tous les points d'entrée Rust
use core::panic::PanicInfo;
/// Cette fonction est invoquée lorsque le système panique
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // ne pas massacrer le nom de cette fonction
pub extern "C" fn _start() -> ! {
// cette fonction est le point d'entrée, puisque le lieur cherche une fonction
// nommée `_start` par défaut
loop {}
}
```
Notez que le point d'entrée doit être appelé `_start` indépendamment du système d'exploitation hôte.
Nous pouvons maintenant construire le noyau pour notre nouvelle cible en fournissant le nom du fichier JSON comme `--target`:
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
Cela échoue! L'erreur nous dit que le compilateur ne trouve plus la [bibliothèque `core`][`core` library]. Cette bibliothèque contient les types de base Rust comme `Result`, `Option`, les itérateurs, et est implicitement liée à toutes les crates `no_std`.
[`core` library]: https://doc.rust-lang.org/nightly/core/index.html
Le problème est que la bibliothèque `core` est distribuée avec le compilateur Rust comme biliothèque _precompilée_. Donc, elle est seulement valide pour les triplets d'hôtes supportés (par exemple, `x86_64-unknown-linux-gnu`) mais pas pour notre cible personnalisée. Si nous voulons compiler du code pour d'autres cibles, nous devons d'abord recompiler `core` pour ces cibles.
#### L'option `build-std`
C'est ici que la [fonctionnalité `build-std`][`build-std` feature] de cargo entre en jeu. Elle permet de recompiler `core` et d'autres crates de la bibliothèque standard sur demande, plutôt que d'utiliser des versions précompilées incluses avec l'installation de Rust. Cette fonctionnalité est très récente et n'est pas encore complète, donc elle est définie comme instable et est seulement disponible avec les [versions nocturnes du compilateur Rust][nightly Rust compilers].
[`build-std` feature]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[nightly Rust compilers]: #installer-une-version-nocturne-de-rust
Pour utiliser cette fonctionnalité, nous devons créer un fichier de [configuration cargo][cargo configuration] dans `.cargo/config.toml` avec le contenu suivant:
```toml
# dans .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
```
Ceci indique à cargo qu'il doit recompiler les bibliothèques `core` et `compiler_builtins`. Celle-ci est nécessaire pour qu'elle ait une dépendance de `core`. Afin de recompiler ces bibliothèques, cargo doit avoir accès au code source de Rust, que nous pouvons installer avec `rustup component add rust-src`.
<div class="note">
**Note:** La clé de configuration `unstable.build-std` nécessite une version nocturne de Rust plus récente que 2020-07-15.
</div>
Après avoir défini la clé de configuration `unstable.build-std` et installé la composante `rust-src`, nous pouvons exécuter notre commande de construction à nouveau:
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
Nous voyons que `cargo build` recompile maintenant les bibliothèques `core`, `rustc-std-workspace-core` (une dépendance de `compiler_builtins`), et `compiler_builtins` pour notre cible personnalisée.
#### Détails reliés à la mémoire
Le compilateur Rust assume qu'un certain ensemble de fonctions intégrées sont disponibles pour tous les systèmes. La plupart de ces fonctions sont fournies par la crate `compiler_builtins` que nous venons de recompiler. Toutefois, certaines fonctions liées à la mémoire dans cette crate ne sont pas activées par défaut puisqu'elles sont normalement fournies par la bibliothèque C sur le système. Parmi ces fonctions, on retrouve `memset`, qui définit tous les octets dans un bloc mémoire à une certaine valeur, `memcpy`, qui copie un bloc mémoire vers un autre, et `memcmp`, qui compare deux blocs mémoire. Alors que nous n'avions pas besoin de ces fonctions pour compiler notre noyau maintenant, elles seront nécessaires aussitôt que nous lui ajouterons plus de code (par exemple, lorsque nous copierons des `struct`).
Puisque nous ne pouvons pas lier avec la bibliothèque C du système d'exploitation, nous avons besoin d'une méthode alternative de fournir ces fonctions au compilateur. Une approche possible pour ce faire serait d'implémenter nos propre fonctions `memset`, etc. et de leur appliquer l'attribut `#[no_mangle]` (pour prévenir le changement de nom automatique pendant la compilation). Or, ceci est dangereux puisque toute erreur dans l'implémentation pourrait mener à un comportement indéterminé. Par exemple, implémenter `memcpy` avec une boucle `for` pourrait mener à une recursion infinie puisque les boucles `for` invoquent implicitement la méthode _trait_ [`IntoIterator::into_iter`], qui pourrait invoquer `memcpy` de nouveau. C'est donc une bonne idée de plutôt réutiliser des implémentations existantes et éprouvées.
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
Heureusement, la crate `compiler_builtins` contient déjà des implémentations pour toutes les fonctions nécessaires, elles sont seulement désactivées par défaut pour ne pas interférer avec les implémentations de la bibliothèque C. Nous pouvons les activer en définissant le drapeau [`build-std-features`] de cargo à `["compiler-builtins-mem"]`. Comme pour le drapeau `build-std`, ce drapeau peut être soit fourni en ligne de commande avec `-Z` ou configuré dans la table `unstable` du fichier `.cargo/config.toml`. Puisque nous voulons toujours construire avec ce drapeau, l'option du fichier de configuration fait plus de sens pour nous:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# dans .cargo/config.toml
[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "compiler_builtins"]
```
(Le support pour la fonctionnalité `compiler-builtins-mem` a [été ajouté assez récemment](https://github.com/rust-lang/rust/pull/77284), donc vous aurez besoin de la version nocturne `2020-09-30` de Rust ou plus récent pour l'utiliser.)
Dans les coulisses, ce drapeau active la [fonctionnalité `mem`][`mem` feature] de la crate `compiler_builtins`. Le résultat est que l'attribut `#[no_mangle]` est appliqué aux [implémentations `memcpy` et autres][`memcpy` etc. implementations] de la caise, ce qui les rend disponible au lieur.
[`mem` feature]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L54-L55
[`memcpy` etc. implementations]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
Avec ce changement, notre noyau a des implémentations valides pour toutes les fonctions requises par le compilateur, donc il peut continuer à compiler même si notre code devient plus complexe.
#### Définir une cible par défaut
Pour ne pas avoir à fournir le paramètre `--target` à chaque invocation de `cargo build`, nous pouvons définir la cible par défaut. Pour ce faire, nous ajoutons le code suivant à notre fichier de [configuration Cargo][cargo configuration] dans `.cargo/config.toml`:
[cargo configuration]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# dans .cargo/config.toml
[build]
target = "x86_64-blog_os.json"
```
Ceci indique à `cargo` d'utiliser notre cible `x86_64-blog_os.json` quand il n'y a pas d'argument de cible `--target` explicitement fourni. Ceci veut dire que nous pouvons maintenant construire notre noyau avec un simple `cargo build`. Pour plus d'informations sur les options de configuration cargo, jetez un coup d'oeil à la [documentation officielle de cargo][cargo configuration].
Nous pouvons maintenant construire notre noyau pour une cible "bare metal" avec un simple `cargo build`. Toutefois, notre point d'entrée `_start`, qui sera appelé par le bootloader, est encore vide. Il est temps de lui faire afficher quelque chose à l'écran.
### Afficher à l'écran
La façon la plus facile d'afficher à l'écran à ce stade est grâce au tampon texte VGA. C'est un emplacement mémoire spécial associé au matériel VGA qui contient le contenu affiché à l'écran. Il consiste normalement en 25 lines qui contiennent chacune 80 cellules de caractère. Chaque cellule de caractère affiche un caractère ASCII avec des couleurs d'avant-plan et d'arrière-plan. Le résultat à l'écran ressemble à ceci:
[VGA text buffer]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
![sortie à l'écran pour des caractères ASCII ordinaires](https://upload.wikimedia.org/wikipedia/commons/f/f8/Codepage-437.png)
Nous discuterons de la disposition exacte du tampon VGA dans le prochain article, où nous lui écrirons un premier petit pilote. Pour afficher “Hello World!”, nous devons seulement savoir que le tampon est situé à l'adresse `0xb8000` et que chaque cellule de caractère consiste en un octet ASCII et un octet de couleur.
L'implémentation ressemble à ceci :
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
D'abord, nous transformons l'entier `0xb8000` en un [pointeur brut][raw pointer]. Puis nous [parcourons][iterate] les octets de la [chaîne d'octets][byte string] [statique][static] `HELLO`. Nous utilisons la méthode [`enumerate`] pour aussi obtenir une variable `i`. Dans le corps de la boucle `for`, nous utilisons la méthode [`offset`] pour écrire la chaîne d'octets et l'octet de couleur correspondant(`0xb` est un cyan pâle).
[iterate]: https://doc.rust-lang.org/stable/book/ch13-02-iterators.html
[static]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
[byte string]: https://doc.rust-lang.org/reference/tokens.html#byte-string-literals
[raw pointer]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
Notez qu'il y a un bloc [`unsafe`] qui enveloppe les écritures mémoire. La raison en est que le compilateur Rust ne peut pas prouver que les pointeurs bruts que nous créons sont valides. Ils pourraient pointer n'importe où et mener à une corruption de données. En les mettant dans un bloc `unsafe`, nous disons fondamentalement au compilateur que nous sommes absolument certains que les opérations sont valides. Notez qu'un bloc `unsafe` ne désactive pas les contrôles de sécurité de Rust. Il permet seulement de faire [cinq choses supplémentaires][five additional things].
[`unsafe`]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html
[five additional things]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#unsafe-superpowers
Je veux souligner que **ce n'est pas comme cela que les choses se font en Rust!** Il est très facile de faire des erreurs en travaillant avec des pointeurs bruts à l'intérieur de blocs `unsafe`. Par exemple, nous pourrions facilement écrire au-delà de la fin du tampon si nous ne sommes pas prudents.
Alors nous voulons minimiser l'utilisation de `unsafe` autant que possible. Rust nous offre la possibilité de le faire en créant des abstractions de sécurité. Par exemple, nous pourrions créer un type tampon VGA qui encapsule les risques et qui s'assure qu'il est impossible de faire quoi que ce soit d'incorrect à l'extérieur de ce type. Ainsi, nous aurions besoin de très peu de code `unsafe` et nous serions certains que nous ne violons pas la [sécurité de mémoire][memory safety]. Nous allons créer une telle abstraction de tampon VGA buffer dans le prochain article.
[memory safety]: https://en.wikipedia.org/wiki/Memory_safety
## Exécuter notre noyau
Maintenant que nous avons un exécutable qui fait quelque chose de perceptible, il est temps de l'exécuter. D'abord, nous devons transformer notre noyau compilé en une image de disque amorçable en le liant à un bootloader. Ensuite, nous pourrons exécuter l'image de disque dans une machine virtuelle [QEMU] ou l'amorcer sur du véritable matériel en utilisant une clé USB.
### Créer une image d'amorçage
Pour transformer notre noyau compilé en image de disque amorçable, nous devons le lier avec un bootloader. Comme nous l'avons appris dans la [section à propos du lancement][section about booting], le bootloader est responsable de l'initialisation du processeur et du chargement de notre noyau.
[section about booting]: #le-processus-d-amorcage
Plutôt que d'écrire notre propre bootloader, ce qui est un projet en soi, nous utilisons la crate [`bootloader`]. Cette crate propose un bootloader BIOS de base sans dépendance C. Seulement du code Rust et de l'assembleur intégré. Pour l'utiliser afin de lancer notre noyau, nous devons ajouter une dépendance à cette crate:
[`bootloader`]: https://crates.io/crates/bootloader
```toml
# dans Cargo.toml
[dependencies]
bootloader = "0.9.8"
```
Ajouter le bootloader comme dépendance n'est pas suffisant pour réellement créer une image de disque amorçable. Le problème est que nous devons lier notre noyau avec le bootloader après la compilation, mais cargo ne supporte pas les [scripts post-build][post-build scripts].
[post-build scripts]: https://github.com/rust-lang/cargo/issues/545
Pour résoudre ce problème, nous avons créé un outil nommé `bootimage` qui compile d'abord le noyau et le bootloader, et les lie ensuite ensemble pour créer une image de disque amorçable. Pour installer cet outil, exécutez la commande suivante dans votre terminal:
```
cargo install bootimage
```
Pour exécuter `bootimage` et construire le bootloader, vous devez avoir la composante rustup `llvm-tools-preview` installée. Vous pouvez l'installer en exécutant `rustup component add llvm-tools-preview`.
Après avoir installé `bootimage` et ajouté la composante `llvm-tools-preview`, nous pouvons créer une image de disque amorçable en exécutant:
```
> cargo bootimage
```
Nous voyons que l'outil recompile notre noyau en utilisant `cargo build`, donc il utilisera automatiquement tout changements que vous faites. Ensuite, il compile le bootloader, ce qui peut prendre un certain temps. Comme toutes les dépendances de crates, il est seulement construit une fois puis il est mis en cache, donc les builds subséquentes seront beaucoup plus rapides. Enfin, `bootimage` combine le bootloader et le noyau en une image de disque amorçable.
Après avoir exécuté la commande, vous devriez voir une image de disque amorçable nommée `bootimage-blog_os.bin` dans votre dossier `target/x86_64-blog_os/debug`. Vous pouvez la lancer dans une machine virtuelle ou la copier sur une clé USB pour la lancer sur du véritable matériel. (Notez que ceci n'est pas une image CD, qui est un format différent, donc la graver sur un CD ne fonctionne pas).
#### Comment cela fonctionne-t-il?
L'outil `bootimage` effectue les étapes suivantes en arrière-plan:
- Il compile notre noyau en un fichier [ELF].
- Il compile notre dépendance bootloader en exécutable autonome.
- Il lie les octets du fichier ELF noyau au bootloader.
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[rust-osdev/bootloader]: https://github.com/rust-osdev/bootloader
Lorsque lancé, le bootloader lit et analyse le fichier ELF ajouté. Il associe ensuite les segments du programme aux adresses virtuelles dans les tables de pages, réinitialise la section `.bss`, puis met en place une pile. Finalement, il lit le point d'entrée (notre fonction `_start`) et s'y rend.
### Amorçage dans QEMU
Nous pouvons maintenant lancer l'image disque dans une machine virtuelle. Pour la démarrer dans [QEMU], exécutez la commande suivante :
[QEMU]: https://www.qemu.org/
```
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]
```
Ceci ouvre une fenêtre séparée qui devrait ressembler à cela:
![QEMU showing "Hello World!"](qemu.png)
Nous voyoons que notre "Hello World!" est visible à l'écran.
### Véritable ordinateur
Il est aussi possible d'écrire l'image disque sur une clé USB et de le lancer sur un véritable ordinateur, **mais soyez prudent** et choisissez le bon nom de périphérique, parce que **tout sur ce périphérique sera écrasé**:
```
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
`sdX` est le nom du périphérique de votre clé USB.
Après l'écriture de l'image sur votre clé USB, vous pouvez l'exécuter sur du véritable matériel en l'amorçant à partir de la clé USB. Vous devrez probablement utiliser un menu d'amorçage spécial ou changer l'ordre d'amorçage dans votre configuration BIOS pour amorcer à partir de la clé USB. Notez que cela ne fonctionne actuellement pas avec des ordinateurs UEFI, puisque la crate `bootloader` ne supporte pas encore UEFI.
### Utilisation de `cargo run`
Pour faciliter l'exécution de notre noyau dans QEMU, nous pouvons définir la clé de configuration `runner` pour cargo:
```toml
# dans .cargo/config.toml
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
La table `target.'cfg(target_os = "none")'` s'applique à toutes les cibles dont le champ `"os"` dans le fichier de configuration est défini à `"none"`. Ceci inclut notre cible `x86_64-blog_os.json`. La clé `runner` key spécifie la commande qui doit être invoquée pour `cargo run`. La commande est exécutée après une build réussie avec le chemin de l'exécutable comme premier argument. Voir la [configuration cargo][cargo configuration] pour plus de détails.
La commande `bootimage runner` est spécifiquement conçue pour être utilisable comme un exécutable `runner`. Elle lie l'exécutable fourni avec le bootloader duquel dépend le projet et lance ensuite QEMU. Voir le [README de `bootimage`][Readme of `bootimage`] pour plus de détails et les options de configuration possibles.
[Readme of `bootimage`]: https://github.com/rust-osdev/bootimage
Nous pouvons maintenant utiliser `cargo run` pour compiler notre noyau et le lancer dans QEMU.
## Et ensuite?
Dans le prochain article, nous explorerons le tampon texte VGA plus en détails et nous écrirons une interface sécuritaire pour l'utiliser. Nous allons aussi mettre en place la macro `println`.

View File

@@ -1,500 +0,0 @@
+++
title = "Rustでつくる最小のカーネル"
weight = 2
path = "ja/minimal-rust-kernel"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "7212ffaa8383122b1eb07fe1854814f99d2e1af4"
# GitHub usernames of the people that translated this post
translators = ["woodyZootopia", "JohnTitor"]
+++
この記事では、Rustで最小限の64bitカーネルを作ります。前の記事で作った[フリースタンディングなRustバイナリ][freestanding Rust binary]を下敷きにして、何かを画面に出力する、ブータブルディスクイメージを作ります。
[freestanding Rust binary]: @/edition-2/posts/01-freestanding-rust-binary/index.ja.md
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-02` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## <ruby>起動<rp> (</rp><rt>Boot</rt><rp>) </rp></ruby>のプロセス {#the-boot-process}
コンピュータを起動すると、マザーボードの [ROM] に保存されたファームウェアのコードを実行し始めます。このコードは、[<ruby>起動時の自己テスト<rp> (</rp><rt>power-on self test</rt><rp>) </rp></ruby>][power-on self-test]を実行し、使用可能なRAMを検出し、CPUとハードウェアを<ruby>事前初期化<rp> (</rp><rt>pre-initialize</rt><rp>) </rp></ruby>します。その後、<ruby>ブータブル<rp> (</rp><rt>bootable</rt><rp>) </rp></ruby>ディスクを探し、オペレーティングシステムのカーネルを<ruby>起動<rp> (</rp><rt>boot</rt><rp>) </rp></ruby>します。
[ROM]: https://ja.wikipedia.org/wiki/Read_only_memory
[power-on self-test]: https://ja.wikipedia.org/wiki/Power_On_Self_Test
x86には2つのファームウェアの標準規格があります"Basic Input/Output System" (**[BIOS]**) と、より新しい "Unified Extensible Firmware Interface" (**[UEFI]**) です。BIOS規格は古く時代遅れですが、シンプルでありすべてのx86のマシンで1980年代からよくサポートされています。対して、UEFIはより現代的でずっと多くの機能を持っていますが、セットアップが複雑です少なくとも私はそう思います
[BIOS]: https://ja.wikipedia.org/wiki/Basic_Input/Output_System
[UEFI]: https://ja.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface
今の所、このブログではBIOSしかサポートしていませんが、UEFIのサポートも計画中です。お手伝いいただける場合は、[GitHubのissue](https://github.com/phil-opp/blog_os/issues/349)をご覧ください。
### BIOSの起動
ほぼすべてのx86システムがBIOSによる起動をサポートしています。これは近年のUEFIベースのマシンも例外ではなく、それらはエミュレートされたBIOSを使います。前世紀のすべてのマシンにも同じブートロジックが使えるなんて素晴らしいですね。しかし、この広い互換性は、BIOSによる起動の最大の欠点でもあるのです。というのもこれは、1980年代の化石のようなブートローダーを動かすために、CPUが[<ruby>リアルモード<rp> (</rp><rt>real mode</rt><rp>) </rp></ruby>][real mode]と呼ばれる16bit互換モードにされてしまうということを意味しているからです。
まあ順を追って見ていくこととしましょう。
コンピュータは起動時にマザーボードにある特殊なフラッシュメモリからBIOSを読み込みます。BIOSは自己テストとハードウェアの初期化ルーチンを実行し、ブータブルディスクを探します。ディスクが見つかると、 **<ruby>ブートローダー<rp> (</rp><rt>bootloader</rt><rp>) </rp></ruby>** と呼ばれる、その先頭512バイトに保存された実行可能コードへと操作権が移ります。多くのブートローダーのサイズは512バイトより大きいため、通常は512バイトに収まる小さな最初のステージと、その最初のステージによって読み込まれる第2ステージに分けられています。
ブートローダーはディスク内のカーネルイメージの場所を特定し、メモリに読み込まなければなりません。また、CPUを16bitの[リアルモード][real mode]から32bitの[<ruby>プロテクトモード<rp> (</rp><rt>protected mode</rt><rp>) </rp></ruby>][protected mode]へ、そして64bitの[<ruby>ロングモード<rp> (</rp><rt>long mode</rt><rp>) </rp></ruby>][long mode]――64bitレジスタとすべてのメインメモリが利用可能になります――へと変更しなければなりません。3つ目の仕事は、特定の情報例えばメモリーマップなどですをBIOSから聞き出し、OSのカーネルに渡すことです。
[real mode]: https://ja.wikipedia.org/wiki/リアルモード
[protected mode]: https://ja.wikipedia.org/wiki/プロテクトモード
[long mode]: https://en.wikipedia.org/wiki/Long_mode
[memory segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
ブートローダーを書くのにはアセンブリ言語を必要とするうえ、「何も考えずにプロセッサーのこのレジスタにこの値を書き込んでください」のような勉強の役に立たない作業がたくさんあるので、ちょっと面倒くさいです。ですのでこの記事ではブートローダーの制作については飛ばして、代わりに[bootimage]という、自動でカーネルの前にブートローダを置いてくれるツールを使いましょう。
[bootimage]: https://github.com/rust-osdev/bootimage
自前のブートローダーを作ることに興味がある人もご期待下さい、これに関する記事も計画中です!
#### Multiboot標準規格
すべてのオペレーティングシステムが、自身にのみ対応しているブートローダーを実装するということを避けるために、1995年に[フリーソフトウェア財団][Free Software Foundation]が[Multiboot]というブートローダーの公開標準規格を策定しています。この標準規格では、ブートローダーとオペレーティングシステムのインターフェースが定義されており、Multibootに準拠したブートローダーであれば、同じくそれに準拠したすべてのオペレーティングシステムが読み込めるようになっています。そのリファレンス実装として、Linuxシステムで一番人気のブートローダーである[GNU GRUB]があります。
[Free Software Foundation]: https://ja.wikipedia.org/wiki/フリーソフトウェア財団
[Multiboot]: https://wiki.osdev.org/Multiboot
[GNU GRUB]: https://ja.wikipedia.org/wiki/GNU_GRUB
カーネルをMultibootに準拠させるには、カーネルファイルの先頭にいわゆる[Multiboot header]を挿入するだけで済みます。このおかげで、OSをGRUBで起動するのはとても簡単です。しかし、GRUBとMultiboot標準規格にはいくつか問題もあります
[Multiboot header]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format
- これらは32bitプロテクトモードしかサポートしていません。そのため、64bitロングモードに変更するためのCPUの設定は依然行う必要があります。
- これらは、カーネルではなくブートローダーがシンプルになるように設計されています。例えば、カーネルは[通常とは異なるデフォルトページサイズ][adjusted default page size]でリンクされる必要があり、そうしないとGRUBはMultiboot headerを見つけることができません。他にも、カーネルに渡される[<ruby>ブート情報<rp> (</rp><rt>boot information</rt><rp>) </rp></ruby>][boot information]は、クリーンな抽象化を与えてくれず、アーキテクチャ依存の構造を多く含んでいます。
- GRUBもMultiboot標準規格もドキュメントが充実していません。
- カーネルファイルからブータブルディスクイメージを作るには、ホストシステムにGRUBがインストールされている必要があります。これにより、MacとWindows上での開発は比較的難しくなっています。
[adjusted default page size]: https://wiki.osdev.org/Multiboot#Multiboot_2
[boot information]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format
これらの欠点を考慮し、私達はGRUBとMultiboot標準規格を使わないことに決めました。しかし、あなたのカーネルをGRUBシステム上で読み込めるように、私達の[bootimage]ツールにMultibootのサポートを追加することも計画しています。Multiboot準拠なカーネルを書きたい場合は、このブログシリーズの[第1版][first edition]をご覧ください。
[first edition]: @/edition-1/_index.md
### UEFI
今の所UEFIのサポートは提供していませんが、ぜひともしたいと思っていますお手伝いいただける場合は、 [GitHub issue](https://github.com/phil-opp/blog_os/issues/349)で教えてください。)
## 最小のカーネル
どのようにコンピュータが起動するのかについてざっくりと理解できたので、自前で最小のカーネルを書いてみましょう。目標は、起動したら画面に"Hello, World!"と出力するようなディスクイメージを作ることです。というわけで、前の記事の[<ruby>独立した<rp> (</rp><rt>freestanding</rt><rp>) </rp></ruby>Rustバイナリ][freestanding Rust binary]をもとにして作っていきます。
覚えていますか、この独立したバイナリは`cargo`を使ってビルドしましたが、オペレーティングシステムに依って異なるエントリポイント名とコンパイルフラグが必要なのでした。これは`cargo`は標準では **ホストシステム**あなたの使っているシステム向けにビルドするためです。例えばWindows上で走るカーネルというのはあまり意味がなく、私達の望む動作ではありません。代わりに、明確に定義された **ターゲットシステム** 向けにコンパイルできると理想的です。
### RustのNightly版をインストールする {#installing-rust-nightly}
Rustには**stable**、**beta**、**nightly**の3つのリリースチャンネルがあります。Rust Bookはこれらの3つのチャンネルの違いをとても良く説明しているので、一度[確認してみてください](https://doc.rust-jp.rs/book-ja/appendix-07-nightly-rust.html)。オペレーティングシステムをビルドするには、nightlyチャンネルでしか利用できないいくつかの実験的機能を使う必要があるので、Rustのnightly版をインストールすることになります。
Rustの実行環境を管理するのには、[rustup]を強くおすすめします。nightly、beta、stable版のコンパイラをそれぞれインストールすることができますし、アップデートするのも簡単です。現在のディレクトリにnightlyコンパイラを使うようにするには、`rustup override set nightly`と実行してください。もしくは、`rust-toolchain`というファイルに`nightly`と記入してプロジェクトのルートディレクトリに置くことでも指定できます。Nightly版を使っていることは、`rustc --version`と実行することで確かめられます。表示されるバージョン名の末尾に`-nightly`とあるはずです。
[rustup]: https://www.rustup.rs/
nightlyコンパイラでは、いわゆる**feature flag**をファイルの先頭につけることで、いろいろな実験的機能を使うことを選択できます。例えば、`#![feature(asm)]``main.rs`の先頭につけることで、インラインアセンブリのための実験的な[`asm!`マクロ][`asm!` macro]を有効化することができます。ただし、これらの実験的機能は全くもって<ruby>不安定<rp> (</rp><rt>unstable</rt><rp>) </rp></ruby>であり、将来のRustバージョンにおいては事前の警告なく変更されたり取り除かれたりする可能性があることに注意してください。このため、絶対に必要なときにのみこれらを使うことにします。
[`asm!` macro]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### ターゲットの仕様
Cargoは`--target`パラメータを使ってさまざまなターゲットをサポートします。ターゲットはいわゆる[target <ruby>triple<rp> (</rp><rt>3つ組</rt><rp>) </rp></ruby>][target triple]によって表されます。これはCPUアーキテクチャ、製造元、オペレーティングシステム、そして[ABI]を表します。例えば、`x86_64-unknown-linux-gnu`というtarget tripleは、`x86_64`のCPU、製造元不明、GNU ABIのLinuxオペレーティングシステム向けのシステムを表します。Rustは[多くのtarget triple][platform-support]をサポートしており、その中にはAndroidのための`arm-linux-androideabi`や[WebAssemblyのための`wasm32-unknown-unknown`](https://www.hellorust.com/setup/wasm-target/)などがあります。
[target triple]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
[ABI]: https://stackoverflow.com/a/2456882
[platform-support]: https://forge.rust-lang.org/release/platform-support.html
[custom-targets]: https://doc.rust-lang.org/nightly/rustc/targets/custom.html
しかしながら、私達のターゲットシステムには、いくつか特殊な設定パラメータが必要になります例えば、その下ではOSが走っていない、など。なので、[既存のtarget triple][platform-support]はどれも当てはまりません。ありがたいことに、RustではJSONファイルを使って[独自のターゲット][custom-targets]を定義できます。例えば、`x86_64-unknown-linux-gnu`というターゲットを表すJSONファイルはこんな感じです。
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
ほとんどのフィールドはLLVMがそのプラットフォーム向けのコードを生成するために必要なものです。例えば、[`data-layout`]フィールドは種々の整数、浮動小数点数、ポインタ型の大きさを定義しています。次に、`target-pointer-width`のような、条件付きコンパイルに用いられるフィールドがあります。第3の種類のフィールドはクレートがどのようにビルドされるべきかを定義します。例えば、`pre-link-args`フィールドは[<ruby>リンカ<rp> (</rp><rt>linker</rt><rp>) </rp></ruby>][linker]に渡される引数を指定しています。
[`data-layout`]: https://llvm.org/docs/LangRef.html#data-layout
[linker]: https://ja.wikipedia.org/wiki/リンケージエディタ
私達のカーネルも`x86_64`のシステムをターゲットとするので、私達のターゲット仕様も上のものと非常によく似たものになるでしょう。`x86_64-blog_os.json`というファイル(お好きな名前を選んでください)を作り、共通する要素を埋めるところから始めましょう。
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
<ruby>ベアメタル<rp> (</rp><rt>bare metal</rt><rp>) </rp></ruby>環境で実行するので、`llvm-target`のOSを変え、`os`フィールドを`none`にしたことに注目してください。
以下の、ビルドに関係する項目を追加します。
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
私達のカーネルをリンクするのに、プラットフォーム標準のLinuxターゲットをサポートしていないかもしれないリンカではなく、Rustに付属しているクロスプラットフォームの[LLD]リンカを使用します。
[LLD]: https://lld.llvm.org/
```json
"panic-strategy": "abort",
```
この設定は、ターゲットがパニック時の[stack unwinding]をサポートしていないので、プログラムは代わりに直接<ruby>中断<rp> (</rp><rt>abort</rt><rp>) </rp></ruby>しなければならないということを指定しています。これは、Cargo.tomlに`panic = "abort"`という設定を書くのに等しいですから、後者の設定を消しても構いませんこのターゲット設定は、Cargo.tomlの設定と異なり、このあと行う`core`ライブラリの再コンパイルにも適用されます。ですので、Cargo.tomlに設定する方が好みだったとしても、この設定を追加するようにしてください
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
```json
"disable-redzone": true,
```
カーネルを書いている以上、ある時点で<ruby>割り込み<rp> (</rp><rt>interrupt</rt><rp>) </rp></ruby>を処理しなければならなくなるでしょう。これを安全に行うために、 **"red zone"** と呼ばれる、ある種のスタックポインタ最適化を無効化する必要があります。こうしないと、スタックの<ruby>破損<rp> (</rp><rt>corruption</rt><rp>) </rp></ruby>を引き起こしてしまう恐れがあるためです。より詳しくは、[red zoneの無効化][disabling the red zone]という別記事をご覧ください。
[disabling the red zone]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.md
```json
"features": "-mmx,-sse,+soft-float",
```
`features`フィールドは、ターゲットの<ruby>機能<rp> (</rp><rt>features</rt><rp>) </rp></ruby>を有効化/無効化します。マイナスを前につけることで`mmx``sse`という機能を無効化し、プラスを前につけることで`soft-float`という機能を有効化しています。それぞれのフラグの間にスペースは入れてはならず、もしそうするとLLVMが機能文字列の解釈に失敗してしまうことに注意してください。
`mmx``sse`という機能は、[Single Instruction Multiple Data (SIMD)]命令をサポートするかを決定します。この命令は、しばしばプログラムを著しく速くしてくれます。しかし、大きなSIMDレジスタをOSカーネルで使うことは性能上の問題に繋がります。 その理由は、カーネルは、割り込まれたプログラムを再開する前に、すべてのレジスタを元に戻さないといけないためです。これは、カーネルがSIMDの状態のすべてを、システムコールやハードウェア割り込みがあるたびにメインメモリに保存しないといけないということを意味します。SIMDの状態情報はとても巨大512〜1600 bytesで、割り込みは非常に頻繁に起こるかもしれないので、保存・復元の操作がこのように追加されるのは性能にかなりの悪影響を及ぼします。これを避けるために、カーネルの上で走っているアプリケーションではなくカーネル上でSIMDを無効化するのです。
[Single Instruction Multiple Data (SIMD)]: https://ja.wikipedia.org/wiki/SIMD
SIMDを無効化することによる問題に、`x86_64`における浮動小数点演算は標準ではSIMDレジスタを必要とするということがあります。この問題を解決するため、`soft-float`機能を追加します。これは、すべての浮動小数点演算を通常の整数に基づいたソフトウェア上の関数を使ってエミュレートするというものです。
より詳しくは、[SIMDを無効化する](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.md)ことに関する私達の記事を読んでください。
#### まとめると
私達のターゲット仕様ファイルは今このようになっているはずです。
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### カーネルをビルドする
私達の新しいターゲットのコンパイルにはLinuxの慣習に倣います理由は知りません、LLVMのデフォルトであるというだけではないでしょうか。つまり、[前の記事][previous post]で説明したように`_start`という名前のエントリポイントが要るということです。
[previous post]: @/edition-2/posts/01-freestanding-rust-binary/index.ja.md
```rust
// src/main.rs
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
```
ホストOSが何であるかにかかわらず、エントリポイントは`_start`という名前でなければならないことに注意してください。
これで、私達の新しいターゲットのためのカーネルを、JSONファイル名を`--target`として渡すことでビルドできるようになりました。
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
失敗しましたねエラーはRustコンパイラが[`core`ライブラリ][`core` library]を見つけられなくなったと言っています。このライブラリは、`Result``Option`、イテレータのような基本的なRustの型を持っており、暗黙のうちにすべての`no_std`なクレートにリンクされています。
[`core` library]: https://doc.rust-lang.org/nightly/core/index.html
問題は、coreライブラリはRustコンパイラと一緒に<ruby>コンパイル済み<rp> (</rp><rt>precompiled</rt><rp>) </rp></ruby>ライブラリとして配布されているということです。そのため、これは、私達独自のターゲットではなく、サポートされているhost triple例えば `x86_64-unknown-linux-gnu`)でのみ使えるのです。他のターゲットのためにコードをコンパイルしたいときには、`core`をそれらのターゲットに向けて再コンパイルする必要があります。
#### `build-std`オプション
ここでcargoの[`build-std`機能][`build-std` feature]の出番です。これを使うと`core`やその他の標準ライブラリクレートについて、Rustインストール時に一緒についてくるコンパイル済みバージョンを使う代わりに、必要に応じて再コンパイルすることができます。これはとても新しくまだ完成していないので、<ruby>不安定<rp> (</rp><rt>unstable</rt><rp>) </rp></ruby>機能とされており、[nightly Rustコンパイラ][nightly Rust compilers]でのみ利用可能です。
[`build-std` feature]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[nightly Rust compilers]: #installing-rust-nightly
この機能を使うためには、[cargoの設定][cargo configuration]ファイルを`.cargo/config.toml`に作り、次の内容を書きましょう。
```toml
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
```
これはcargoに`core``compiler_builtins`ライブラリを再コンパイルするよう命令します。後者が必要なのは`core`がこれに依存しているためです。 これらのライブラリを再コンパイルするためには、cargoがRustのソースコードにアクセスできる必要があります。これは`rustup component add rust-src`でインストールできます。
<div class="note">
**注意:** `unstable.build-std`設定キーを使うには、少なくとも2020-07-15以降のRust nightlyが必要です。
</div>
`unstable.build-std`設定キーをセットし、`rust-src`コンポーネントをインストールしたら、ビルドコマンドをもう一度実行しましょう。
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
今回は、`cargo build``core``rustc-std-workspace-core` (`compiler_builtins`の依存です)、そして `compiler_builtins`を私達のカスタムターゲット向けに再コンパイルしているということがわかります。
#### メモリ関係の<ruby>組み込み関数<rp> (</rp><rt>intrinsics</rt><rp>) </rp></ruby>
Rustコンパイラは、すべてのシステムにおいて、特定の組み込み関数が利用可能であるということを前提にしています。それらの関数の多くは、私達がちょうど再コンパイルした`compiler_builtins`クレートによって提供されています。しかしながら、通常システムのCライブラリによって提供されているので標準では有効化されていない、メモリ関係の関数がいくつかあります。それらの関数には、メモリブロック内のすべてのバイトを与えられた値にセットする`memset`、メモリーブロックを他のブロックへとコピーする`memcpy`、2つのメモリーブロックを比較する`memcmp`などがあります。これらの関数はどれも、現在の段階で我々のカーネルをコンパイルするのに必要というわけではありませんが、コードを追加していくとすぐに必要になるでしょう(たとえば、構造体をコピーする、など)。
オペレーティングシステムのCライブラリにリンクすることはできませんので、これらの関数をコンパイラに与えてやる別の方法が必要になります。このための方法として考えられるものの一つが、自前で`memset`を実装し、(コンパイル中の自動リネームを防ぐため)`#[no_mangle]`アトリビュートをこれらに適用することでしょう。しかし、こうすると、これらの関数の実装のちょっとしたミスが未定義動作に繋がりうるため危険です。たとえば、`for`ループを使って`memcpy`を実装すると無限再帰を起こしてしまうかもしれません。なぜなら、`for`ループは暗黙のうちに[`IntoIterator::into_iter`]トレイトメソッドを呼び出しており、これが`memcpy`を再び呼び出しているかもしれないためです。なので、代わりに既存のよくテストされた実装を再利用するのが良いでしょう。
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
ありがたいことに、`compiler_builtins`クレートにはこれらの必要な関数すべての実装が含まれており、標準ではCライブラリの実装と競合しないように無効化されているだけなのです。これはcargoの[`build-std-features`]フラグを`["compiler-builtins-mem"]`に設定することで有効化できます。`build-std`フラグと同じように、このフラグはコマンドラインで`-Z`フラグとして渡すこともできれば、`.cargo/config.toml`ファイルの`unstable`テーブルで設定することもできます。ビルド時は常にこのフラグをセットしたいので、設定ファイルを使う方が良いでしょう:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# in .cargo/config.toml
[unstable]
build-std-features = ["compiler-builtins-mem"]
```
`compiler-builtins-mem`機能のサポートが追加されたのは[つい最近](https://github.com/rust-lang/rust/pull/77284)なので、`2019-09-30`以降のRust nightlyが必要です。
このとき、裏で`compiler_builtins`クレートの[`mem`機能][`mem` feature]が有効化されています。これにより、このクレートの[`memcpy`などの実装][`memcpy` etc. implementations]に`#[no_mangle]`アトリビュートが適用され、リンカがこれらを利用できるようになっています。
[`mem` feature]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L51-L52
[`memcpy` etc. implementations]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
この変更をもって、私達のカーネルはコンパイラに必要とされているすべての関数の有効な実装を手に入れたので、コードがもっと複雑になっても変わらずコンパイルできるでしょう。
#### 標準のターゲットをセットする
`cargo build`を呼び出すたびに`--target`パラメータを渡すのを避けるために、デフォルトのターゲットを書き換えることができます。これをするには、以下を`.cargo/config.toml`の[cargo設定][cargo configuration]ファイルに付け加えます:
[cargo configuration]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# in .cargo/config.toml
[build]
target = "x86_64-blog_os.json"
```
これは、明示的に`--target`引数が渡されていないときは、`x86_64-blog_os.json`ターゲットを使うように`cargo`に命令します。つまり、私達はカーネルをシンプルな`cargo build`コマンドでビルドできるということです。cargoの設定のオプションについてより詳しく知るには、[公式のドキュメント][cargo configuration]を読んでください。
これにより、シンプルな`cargo build`コマンドで、ベアメタルのターゲットに私達のカーネルをビルドできるようになりました。しかし、ブートローダーによって呼び出される私達の`_start`エントリポイントはまだ空っぽです。そろそろここから何かを画面に出力してみましょう。
### 画面に出力する
現在の段階で画面に文字を出力する最も簡単な方法は[VGAテキストバッファ][VGA text buffer]です。これは画面に出力されている内容を保持しているVGAハードウェアにマップされた特殊なメモリです。通常、これは25行からなり、それぞれの行は80文字セルからなります。それぞれの文字セルは、背景色と前景色付きのASCII文字を表示します。画面出力はこのように見えるでしょう
[VGA text buffer]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
![screen output for common ASCII characters](https://upload.wikimedia.org/wikipedia/commons/f/f8/Codepage-437.png)
次の記事では、VGAバッファの正確なレイアウトについて議論し、このためのちょっとしたドライバも書きます。"Hello World!"を出力するためには、バッファがアドレス`0xb8000`にあり、それぞれの文字セルはASCIIのバイトと色のバイトからなることを知っている必要があります。
実装はこんな感じになります:
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
まず、`0xb8000`という整数を[生ポインタ][raw pointer]にキャストします。次に[<ruby>静的<rp> (</rp><rt>static</rt><rp>) </rp></ruby>][static]な`HELLO`という[バイト列][byte string]変数の要素に対し[イテレート][iterate]します。[`enumerate`]メソッドを使うことで、`for` ループの実行回数を表す変数 `i` も取得します。ループの内部では、[`offset`]メソッドを使って文字列のバイトと対応する色のバイト(`0xb`は明るいシアン色)を書き込んでいます。
[iterate]: https://doc.rust-jp.rs/book-ja/ch13-02-iterators.html
[static]: https://doc.rust-jp.rs/book-ja/ch10-03-lifetime-syntax.html#静的ライフタイム
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
[byte string]: https://doc.rust-lang.org/reference/tokens.html#byte-string-literals
[raw pointer]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html#生ポインタを参照外しする
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
すべてのメモリへの書き込み処理のコードを、[<ruby>`unsafe`<rp> (</rp><rt>安全でない</rt><rp>) </rp></ruby>][`unsafe`]ブロックが囲んでいることに注意してください。この理由は、私達の作った生ポインタが正しいものであることをRustコンパイラが証明できないためです。生ポインタはどんな場所でも指しうるので、データの破損につながるかもしれません。これらの操作を`unsafe`ブロックに入れることで、私達はこれが正しいことを確信しているとコンパイラに伝えているのです。ただし、`unsafe`ブロックはRustの安全性チェックを消すわけではなく、[追加で5つのことができるようになる][five additional things]だけということに注意してください。
<div class="note">
**訳注:** 翻訳時点(2020-10-20)では、リンク先のThe Rust book日本語版には「追加でできるようになること」は4つしか書かれていません。
</div>
[`unsafe`]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html
[five additional things]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html#unsafeの強大な力superpower
強調しておきたいのですが、 **このような機能はRustでプログラミングするときに使いたいものではありません** unsafeブロック内で生ポインタを扱うと非常にしくじりやすいです。たとえば、注意不足でバッファの終端のさらに奥に書き込みを行ってしまったりするかもしれません。
ですので、`unsafe`の使用は最小限にしたいです。これをするために、Rustでは安全な<ruby>abstraction<rp> (</rp><rt>抽象化されたもの</rt><rp>) </rp></ruby>を作ることができます。たとえば、VGAバッファ型を作り、この中にすべてのunsafeな操作をカプセル化し、外側からの誤った操作が**不可能**であることを保証できるでしょう。こうすれば、`unsafe`の量を最小限にでき、[メモリ安全性][memory safety]を侵していないことを確かにできます。そのような安全なVGAバッファの abstraction を次の記事で作ります。
[memory safety]: https://ja.wikipedia.org/wiki/メモリ安全性
## カーネルを実行する
では、目で見て分かる処理を行う実行可能ファイルを手に入れたので、実行してみましょう。まず、コンパイルした私達のカーネルを、ブートローダーとリンクすることによってブータブルディスクイメージにする必要があります。そして、そのディスクイメージを、[QEMU]バーチャルマシン内や、USBメモリを使って実際のハードウェア上で実行できます。
### ブートイメージを作る
コンパイルされた私達のカーネルをブータブルディスクイメージに変えるには、ブートローダーとリンクする必要があります。[起動のプロセスのセクション][section about booting]で学んだように、ブートローダーはCPUを初期化しカーネルをロードする役割があります。
[section about booting]: #the-boot-process
自前のブートローダーを書くと、それだけで1つのプロジェクトになってしまうので、代わりに[`bootloader`]クレートを使いましょう。このクレートは、Cに依存せず、Rustとインラインアセンブリだけで基本的なBIOSブートローダーを実装しています。私達のカーネルを起動するためにこれを依存関係に追加する必要があります
[`bootloader`]: https://crates.io/crates/bootloader
```toml
# in Cargo.toml
[dependencies]
bootloader = "0.9.23"
```
bootloaderを依存として加えることだけでブータブルディスクイメージが実際に作れるわけではなく、私達のカーネルをコンパイル後にブートローダーにリンクする必要があります。問題は、cargoが[<ruby>ビルド後<rp> (</rp><rt>post-build</rt><rp>) </rp></ruby>にスクリプトを走らせる機能][post-build scripts]を持っていないことです。
[post-build scripts]: https://github.com/rust-lang/cargo/issues/545
この問題を解決するため、私達は`bootimage`というツールを作りました。これは、まずカーネルとブートローダーをコンパイルし、そしてこれらをリンクしてブータブルディスクイメージを作ります。このツールをインストールするには、以下のコマンドをターミナルで実行してください:
```
cargo install bootimage
```
`bootimage`を実行しブートローダをビルドするには、`llvm-tools-preview`というrustupコンポーネントをインストールする必要があります。これは`rustup component add llvm-tools-preview`と実行することでできます。
`bootimage`をインストールし、`llvm-tools-preview`を追加したら、以下のように実行することでブータブルディスクイメージを作れます:
```
> cargo bootimage
```
このツールが私達のカーネルを`cargo build`を使って再コンパイルしていることがわかります。そのため、あなたの行った変更を自動で検知してくれます。その後、bootloaderをビルドします。これには少し時間がかかるかもしれません。他の依存クレートと同じように、ビルドは一度しか行われず、その都度キャッシュされるので、以降のビルドはもっと早くなります。最終的に、`bootimage`はbootloaderとあなたのカーネルを合体させ、ブータブルディスクイメージにします。
このコマンドを実行したら、`target/x86_64-blog_os/debug`ディレクトリ内に`bootimage-blog_os.bin`という名前のブータブルディスクイメージがあるはずです。これをバーチャルマシン内で起動してもいいですし、実際のハードウェア上で起動するためにUSBメモリにコピーしてもいいでしょうただし、これはCDイメージではありません。CDイメージは異なるフォーマットを持つので、これをCDに焼いてもうまくいきません
#### どういう仕組みなの?
`bootimage`ツールは、裏で以下のステップを行っています:
- 私達のカーネルを[ELF]ファイルにコンパイルする。
- 依存であるbootloaderをスタンドアロンの実行ファイルとしてコンパイルする。
- カーネルのELFファイルのバイト列をブートローダーにリンクする。
[ELF]: https://ja.wikipedia.org/wiki/Executable_and_Linkable_Format
[rust-osdev/bootloader]: https://github.com/rust-osdev/bootloader
起動時、ブートローダーは追加されたELFファイルを読み、解釈します。次にプログラム部を<ruby>ページテーブル<rp> (</rp><rt>page table</rt><rp>) </rp></ruby>の<ruby>仮想アドレス<rp> (</rp><rt>virtual address</rt><rp>) </rp></ruby>にマップし、`.bss`部をゼロにし、スタックをセットアップします。最後に、エントリポイントのアドレス(私達の`_start`関数)を読み、そこにジャンプします。
### QEMUで起動する
これで、ディスクイメージを仮想マシンで起動できます。[QEMU]を使ってこれを起動するには、以下のコマンドを実行してください:
[QEMU]: https://www.qemu.org/
```
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]
```
これにより、以下のような見た目の別のウィンドウが開きます:
![QEMU showing "Hello World!"](qemu.png)
私達の書いた"Hello World!"が画面に見えますね。
### 実際のマシン
USBメモリにこれを書き込んで実際のマシン上で起動することも可能です
```
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
`sdX`はあなたのUSBメモリのデバイス名です。そのデバイス上のすべてのデータが上書きされてしまうので、 **正しいデバイス名を選んでいるのかよく確認してください**
イメージをUSBメモリに書き込んだあとは、そこから起動することによって実際のハードウェア上で走らせることができます。特殊なブートメニューを使ったり、BIOS設定で起動時の優先順位を変え、USBメモリから起動することを選択する必要があるでしょう。ただし、`bootloader`クレートはUEFIをサポートしていないので、UEFIマシン上ではうまく動作しないということに注意してください。
### `cargo run`を使う
QEMU上でより簡単に私達のカーネルを走らせるために、cargoの`runner`設定が使えます。
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
`target.'cfg(target_os = "none")'`テーブルは、`"os"`フィールドが`"none"`であるようなすべてのターゲットに適用されます。私達の`x86_64-blog_os.json`ターゲットもその1つです。`runner`キーは`cargo run`のときに呼ばれるコマンドを指定しています。このコマンドは、ビルドが成功した後に、実行可能ファイルのパスを第一引数として実行されます。詳しくは、[cargoのドキュメント][cargo configuration]を読んでください。
`bootimage runner`コマンドは、`runner`キーとして実行するために設計されています。このコマンドは、与えられた実行ファイルをプロジェクトの依存するbootloaderとリンクして、QEMUを立ち上げます。より詳しく知りたいときや、設定オプションについては[`bootimage`のReadme][Readme of `bootimage`]を読んでください。
[Readme of `bootimage`]: https://github.com/rust-osdev/bootimage
これで、`cargo run`を使ってカーネルをコンパイルしQEMU内で起動することができます。
## 次は?
次の記事では、VGAテキストバッファをより詳しく学び、そのための安全なインターフェースを書きます。また、`println`マクロのサポートも行います。

View File

@@ -1,511 +0,0 @@
+++
title = "최소 기능을 갖춘 커널"
weight = 2
path = "ko/minimal-rust-kernel"
date = 2018-02-10
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "c1af4e31b14e562826029999b9ab1dce86396b93"
# GitHub usernames of the people that translated this post
translators = ["JOE1994", "Quqqu"]
+++
이번 포스트에서는 x86 아키텍처에서 최소한의 기능으로 동작하는 64비트 Rust 커널을 함께 만들 것입니다. 지난 포스트 [Rust로 'Freestanding 실행파일' 만들기][freestanding Rust binary] 에서 작업한 것을 토대로 부팅 가능한 디스크 이미지를 만들고 화면에 데이터를 출력해볼 것입니다.
[freestanding Rust binary]: @/edition-2/posts/01-freestanding-rust-binary/index.md
<!-- more -->
이 블로그는 [GitHub 저장소][GitHub]에서 오픈 소스로 개발되고 있으니, 문제나 문의사항이 있다면 저장소의 'Issue' 기능을 이용해 제보해주세요. [페이지 맨 아래][at the bottom]에 댓글을 남기실 수도 있습니다. 이 포스트와 관련된 모든 소스 코드는 저장소의 [`post-02 브랜치`][post branch]에서 확인하실 수 있습니다.
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## 부팅 과정 {#the-boot-process}
전원이 켜졌을 때 컴퓨터가 맨 처음 하는 일은 바로 마더보드의 [롬 (ROM)][ROM]에 저장된 펌웨어 코드를 실행하는 것입니다.
이 코드는 [시동 자체 시험][power-on self-test]을 진행하고, 사용 가능한 램 (RAM)을 확인하며, CPU 및 하드웨어의 초기화 작업을 진행합니다.
그 후에는 부팅 가능한 디스크를 감지하고 운영체제 커널을 부팅하기 시작합니다.
[ROM]: https://en.wikipedia.org/wiki/Read-only_memory
[power-on self-test]: https://en.wikipedia.org/wiki/Power-on_self-test
x86 시스템에는 두 가지 펌웨어 표준이 존재합니다: 하나는 "Basic Input/Output System"(**[BIOS]**)이고 다른 하나는 "Unified Extensible Firmware Interface" (**[UEFI]**) 입니다. BIOS 표준은 구식 표준이지만, 간단하며 1980년대 이후 출시된 어떤 x86 하드웨어에서도 지원이 잘 됩니다. UEFI는 신식 표준으로서 더 많은 기능들을 갖추었지만, 제대로 설정하고 구동시키기까지의 과정이 더 복잡합니다 (적어도 제 주관적 입장에서는 그렇게 생각합니다).
[BIOS]: https://en.wikipedia.org/wiki/BIOS
[UEFI]: https://en.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface
우리가 만들 운영체제에서는 BIOS 표준만을 지원할 것이지만, UEFI 표준도 지원하고자 하는 계획이 있습니다. UEFI 표준을 지원할 수 있도록 도와주시고 싶다면 [해당 깃헙 이슈][Github issue]를 확인해주세요.
### BIOS 부팅
UEFI 표준으로 동작하는 최신 기기들도 가상 BIOS를 지원하기에, 존재하는 거의 모든 x86 시스템들이 BIOS 부팅을 지원합니다. 덕분에 하나의 BIOS 부팅 로직을 구현하면 여태 만들어진 거의 모든 컴퓨터를 부팅시킬 수 있습니다. 동시에 이 방대한 호환성이 BIOS의 가장 큰 약점이기도 한데,
그 이유는 1980년대의 구식 부트로더들에 대한 하위 호환성을 유지하기 위해 부팅 전에는 항상 CPU를 16비트 호환 모드 ([real mode]라고도 불림)로 설정해야 하기 때문입니다.
이제 BIOS 부팅 과정의 첫 단계부터 살펴보겠습니다:
여러분이 컴퓨터의 전원을 켜면, 제일 먼저 컴퓨터는 마더보드의 특별한 플래시 메모리로부터 BIOS 이미지를 로드합니다. BIOS 이미지는 자가 점검 및 하드웨어 초기화 작업을 처리한 후에 부팅 가능한 디스크가 있는지 탐색합니다. 부팅 가능한 디스크가 있다면, 제어 흐름은 해당 디스크의 _부트로더 (bootloader)_ 에게 넘겨집니다. 이 부트로더는 디스크의 가장 앞 주소 영역에 저장되는 512 바이트 크기의 실행 파일입니다. 대부분의 부트로더들의 경우 로직을 저장하는 데에 512 바이트보다 더 큰 용량이 필요하기에, 부트로더의 로직을 둘로 쪼개어 첫 단계 로직을 첫 512 바이트 안에 담고, 두 번째 단계 로직은 첫 단계 로직에 의해 로드된 이후 실행됩니다.
부트로더는 커널 이미지가 디스크의 어느 주소에 저장되어있는지 알아낸 후 메모리에 커널 이미지를 로드해야 합니다. 그다음 CPU를 16비트 [real mode]에서 32비트 [protected mode]로 전환하고, 그 후에 다시 CPU를 64비트 [long mode]로 전환한 이후부터 64비트 레지스터 및 메인 메모리의 모든 주소를 사용할 수 있게 됩니다. 부트로더가 세 번째로 할 일은 BIOS로부터 메모리 매핑 정보 등의 필요한 정보를 알아내어 운영체제 커널에 전달하는 것입니다.
[real mode]: https://en.wikipedia.org/wiki/Real_mode
[protected mode]: https://en.wikipedia.org/wiki/Protected_mode
[long mode]: https://en.wikipedia.org/wiki/Long_mode
[memory segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
부트로더를 작성하는 것은 상당히 성가신 작업인데, 그 이유는 어셈블리 코드도 작성해야 하고 "A 레지스터에 B 값을 저장하세요" 와 같이 원리를 단번에 이해하기 힘든 작업이 많이 수반되기 때문입니다. 따라서 이 포스트에서는 부트로더를 만드는 것 자체를 다루지는 않고, 대신 운영체제 커널의 맨 앞에 부트로더를 자동으로 추가해주는 [bootimage]라는 도구를 제공합니다.
[bootimage]: https://github.com/rust-osdev/bootimage
본인의 부트로더를 직접 작성하는 것에 흥미가 있으시다면, 이 주제로 여러 포스트가 나올 계획이니 기대해주세요!
#### Multiboot 표준
운영체제마다 부트로더 구현 방법이 다르다면 한 운영체제에서 동작하는 부트로더가 다른 운영체제에서는 호환이 되지 않을 것입니다. 이런 불편한 점을 막기 위해 [Free Software Foundation]에서 1995년에 [Multiboot]라는 부트로더 표준을 개발했습니다. 이 표준은 부트로더와 운영체제 사이의 상호 작용 방식을 정의하였는데, 이 Multiboot 표준에 따르는 부트로더는 Multiboot 표준을 지원하는 어떤 운영체제에서도 동작합니다. 이 표준을 구현한 대표적인 예로 리눅스 시스템에서 가장 인기 있는 부트로더인 [GNU GRUB]이 있습니다.
[Free Software Foundation]: https://en.wikipedia.org/wiki/Free_Software_Foundation
[Multiboot]: https://wiki.osdev.org/Multiboot
[GNU GRUB]: https://en.wikipedia.org/wiki/GNU_GRUB
운영체제 커널이 Multiboot를 지원하게 하려면 커널 파일의 맨 앞에 [Multiboot 헤더][Multiboot header]를 삽입해주면 됩니다. 이렇게 하면 GRUB에서 운영체제를 부팅하는 것이 매우 쉬워집니다. 하지만 GRUB 및 Multiboot 표준도 몇 가지 문제점들을 안고 있습니다:
[Multiboot header]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format
- 오직 32비트 protected mode만을 지원합니다. 64비트 long mode를 이용하고 싶다면 CPU 설정을 별도로 변경해주어야 합니다.
- Multiboot 표준 및 GRUB은 부트로더 구현의 단순화를 우선시하여 개발되었기에, 이에 호응하는 커널 측의 구현이 번거로워진다는 단점이 있습니다. 예를 들어, GRUB이 Multiboot 헤더를 제대로 찾을 수 있으려면 커널 측에서 [조정된 기본 페이지 크기 (adjusted default page size)][adjusted default page size]를 링크하는 것이 강제됩니다. 또한, 부트로더가 커널로 전달하는 [부팅 정보][boot information]는 적절한 추상 레벨에서 표준화된 형태로 전달되는 대신 하드웨어 아키텍처마다 상이한 형태로 제공됩니다.
- GRUB 및 Multiboot 표준에 대한 문서화 작업이 덜 되어 있습니다.
- GRUB이 호스트 시스템에 설치되어 있어야만 커널 파일로부터 부팅 가능한 디스크 이미지를 만들 수 있습니다. 이 때문에 Windows 및 Mac에서는 부트로더를 개발하는 것이 Linux보다 어렵습니다.
[adjusted default page size]: https://wiki.osdev.org/Multiboot#Multiboot_2
[boot information]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format
이러한 단점들 때문에 우리는 GRUB 및 Multiboot 표준을 사용하지 않을 것입니다. 하지만 미래에 우리의 [bootimage] 도구가 Multiboot 표준을 지원하도록 하는 것도 계획 중입니다. Multiboot 표준을 지원하는 운영체제를 커널을 개발하는 것에 관심이 있으시다면, 이 블로그 시리즈의 [첫 번째 에디션][first edition]을 확인해주세요.
[first edition]: @/edition-1/_index.md
### UEFI
(아직 UEFI 표준을 지원하지 않지만, UEFI 표준을 지원할 수 있도록 도와주시려면 해당 [깃헙 이슈](https://github.com/phil-opp/blog_os/issues/349)에 댓글을 남겨주세요!)
## 최소한의 기능을 갖춘 운영체제 커널
컴퓨터의 부팅 과정에 대해서 대략적으로 알게 되었으니, 이제 우리 스스로 최소한의 기능을 갖춘 운영체제 커널을 작성해볼 차례입니다. 우리의 목표는 부팅 이후 화면에 "Hello World!" 라는 메세지를 출력하는 디스크 이미지를 만드는 것입니다. 지난 포스트에서 만든 [freestanding Rust 실행파일][freestanding Rust binary] 을 토대로 작업을 이어나갑시다.
지난 포스트에서 우리는 `cargo`를 통해 freestanding 실행파일을 만들었었는데, 호스트 시스템의 운영체제에 따라 프로그램 실행 시작 지점의 이름 및 컴파일 인자들을 다르게 설정해야 했습니다. 이것은 `cargo`가 기본적으로 _호스트 시스템_ (여러 분이 실행 중인 컴퓨터 시스템) 을 목표로 빌드하기 때문이었습니다. 우리의 커널은 다른 운영체제 (예를 들어 Windows) 위에서 실행될 것이 아니기에, 호스트 시스템에 설정 값을 맞추는 대신에 우리가 명확히 정의한 _목표 시스템 (target system)_ 을 목표로 컴파일할 것입니다.
### Rust Nightly 설치하기 {#installing-rust-nightly}
Rust는 _stable_, _beta_ 그리고 _nightly_ 이렇게 세 가지의 채널을 통해 배포됩니다. Rust Book에 [세 채널들 간의 차이에 대해 잘 정리한 챕터](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html#choo-choo-release-channels-and-riding-the-trains)가 있습니다. 운영체제를 빌드하기 위해서는 _nightly_ 채널에서만 제공하는 실험적인 기능들을 이용해야 하기에 _nightly_ 버전의 Rust를 설치하셔야 합니다.
여러 버전의 Rust 언어 설치 파일들을 관리할 때 [rustup]을 사용하는 것을 강력 추천합니다. rustup을 통해 nightly, beta 그리고 stable 컴파일러들을 모두 설치하고 업데이트할 수 있습니다. `rustup override set nightly` 명령어를 통해 현재 디렉토리에서 항상 nightly 버전의 Rust를 사용하도록 설정할 수 있습니다.
`rust-toolchain`이라는 파일을 프로젝트 루트 디렉토리에 만들고 이 파일에 `nightly`라는 텍스트를 적어 놓아도 같은 효과를 볼 수 있습니다. `rustc --version` 명령어를 통해 현재 nightly 버전이 설치되어 있는지 확인할 수 있습니다 (출력되는 버전 넘버가 `-nightly`라는 텍스트로 끝나야 합니다).
[rustup]: https://www.rustup.rs/
nightly 컴파일러는 _feature 플래그_ 를 소스코드의 맨 위에 추가함으로써 여러 실험적인 기능들을 선별해 이용할 수 있게 해줍니다. 예를 들어, `#![feature(asm)]``main.rs`의 맨 위에 추가하면 [`asm!` 매크로][`asm!` macro]를 사용할 수 있습니다. `asm!` 매크로는 인라인 어셈블리 코드를 작성할 때 사용합니다.
이런 실험적인 기능들은 말 그대로 "실험적인" 기능들이기에 미래의 Rust 버전들에서는 예고 없이 변경되거나 삭제될 수도 있습니다. 그렇기에 우리는 이 실험적인 기능들을 최소한으로만 사용할 것입니다.
[`asm!` macro]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### 컴파일 대상 정의하기
Cargo는 `--target` 인자를 통해 여러 컴파일 대상 시스템들을 지원합니다. 컴파일 대상은 소위 _[target triple]_ 을 통해 표현되는데, CPU 아키텍쳐와 CPU 공급 업체, 운영체제, 그리고 [ABI]를 파악할 수 있습니다. 예를 들어 `x86_64-unknown-linux-gnu``x86_64` CPU, 임의의 CPU 공급 업체, Linux 운영체제, 그리고 GNU ABI를 갖춘 시스템을 나타냅니다. Rust는 Android를 위한 `arm-linux-androideabi`와 [WebAssembly를 위한 `wasm32-unknown-unknown`](https://www.hellorust.com/setup/wasm-target/)를 비롯해 [다양한 target triple들][platform-support]을 지원합니다.
[target triple]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
[ABI]: https://stackoverflow.com/a/2456882
[platform-support]: https://forge.rust-lang.org/release/platform-support.html
[custom-targets]: https://doc.rust-lang.org/nightly/rustc/targets/custom.html
우리가 목표로 하는 컴파일 대상 환경 (운영체제가 따로 없는 환경)을 정의하려면 몇 가지 특별한 설정 인자들을 사용해야 하기에 [Rust 에서 기본적으로 지원하는 target triple][platform-support] 중에서는 우리가 쓸 수 있는 것은 없습니다. 다행히도 Rust에서는 JSON 파일을 이용해 [우리가 목표로 하는 컴파일 대상 환경][custom-targets]을 직접 정의할 수 있습니다. 예를 들어, `x86_64-unknown-linux-gnu` 환경을 직접 정의하는 JSON 파일의 내용은 아래와 같습니다:
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
대부분의 필드 값들은 LLVM이 해당 환경을 목표로 코드를 생성하는 과정에서 필요합니다. 예시로, [`data-layout`] 필드는 다양한 정수, 부동소수점 표기 소수, 포인터 등의 메모리 상 실제 크기를 지정합니다. 또한 `target-pointer-width`와 같이 Rust가 조건부 컴파일을 하는 과정에서 이용하는 필드들도 있습니다.
마지막 남은 종류의 필드들은 crate가 어떻게 빌드되어야 하는지 결정합니다. 예를 들어 `pre-link-args` 필드는 [링커][linker]에 전달될 인자들을 설정합니다.
[`data-layout`]: https://llvm.org/docs/LangRef.html#data-layout
[linker]: https://en.wikipedia.org/wiki/Linker_(computing)
우리도 `x86_64` 시스템에서 구동할 운영체제 커널을 작성할 것이기에, 우리가 사용할 컴파일 대상 환경 환경 설정 파일 (JSON 파일) 또한 위의 내용과 많이 유사할 것입니다. 일단 `x86_64-blog_os.json`이라는 파일을 만들고 아래와 같이 파일 내용을 작성해주세요:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
우리의 운영체제는 bare metal 환경에서 동작할 것이기에, `llvm-target` 필드의 운영체제 값과 `os` 필드의 값은 `none`입니다.
아래의 빌드 관련 설정들을 추가해줍니다:
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
현재 사용 중인 플랫폼의 기본 링커 대신 Rust와 함께 배포되는 크로스 플랫폼 [LLD] 링커를 사용해 커널을 링크합니다 (기본 링커는 리눅스 환경을 지원하지 않을 수 있습니다).
[LLD]: https://lld.llvm.org/
```json
"panic-strategy": "abort",
```
해당 환경이 패닉 시 [스택 되감기][stack unwinding]을 지원하지 않기에, 위 설정을 통해 패닉 시 프로그램이 즉시 실행 종료되도록 합니다. 위 설정은 Cargo.toml 파일에 `panic = "abort"` 설정을 추가하는 것과 비슷한 효과이기에, Cargo.toml에서는 해당 설정을 지우셔도 괜찮습니다 (다만, Cargo.toml에서의 설정과는 달리 이 설정은 이후 단계에서 우리가 `core` 라이브러리를 재컴파일할 때에도 유효하게 적용된다는 점이 중요합니다. 위 설정은 꼭 추가해주세요!).
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
```json
"disable-redzone": true,
```
커널을 작성하려면, 커널이 인터럽트에 대해 어떻게 대응하는지에 대한 로직도 작성하게 될 것입니다. 안전하게 이런 로직을 작성하기 위해서는 _“red zone”_ 이라고 불리는 스택 포인터 최적화 기능을 해제해야 합니다 (그렇지 않으면 해당 기능으로 인해 스택 메모리가 우리가 원치 않는 값으로 덮어쓰일 수 있습니다). 이 내용에 대해 더 자세히 알고 싶으시면 [red zone 기능 해제][disabling the red zone] 포스트를 확인해주세요.
[disabling the red zone]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.ko.md
```json
"features": "-mmx,-sse,+soft-float",
```
`features` 필드는 컴파일 대상 환경의 기능들을 활성화/비활성화 하는 데 이용합니다. 우리는 `-` 기호를 통해 `mmx``sse` 기능들을 비활성화시키고 `+` 기호를 통해 `soft-float` 기능을 활성화시킬 것입니다. `features` 필드의 문자열 내부 플래그들 사이에 빈칸이 없도록 해야 합니다. 그렇지 않으면 LLVM이 `features` 필드의 문자열 값을 제대로 해석하지 못하기 때문입니다.
`mmx``sse`는 [Single Instruction Multiple Data (SIMD)] 명령어들의 사용 여부를 결정하는데, 해당 명령어들은 프로그램의 실행 속도를 훨씬 빠르게 만드는 데에 도움을 줄 수 있습니다. 하지만 운영체제에서 큰 SIMD 레지스터를 사용할 경우 커널의 성능에 문제가 생길 수 있습니다. 그 이유는 커널이 인터럽트 되었던 프로그램을 다시 실행하기 전에 모든 레지스터 값들을 인터럽트 직전 시점의 상태로 복원시켜야 하기 때문입니다. 커널이 SIMD 레지스터를 사용하려면 각 시스템 콜 및 하드웨어 인터럽트가 일어날 때마다 모든 SIMD 레지스터에 저장된 값들을 메인 메모리에 저장해야 할 것입니다. SIMD 레지스터들이 총 차지하는 용량은 매우 크고 (512-1600 바이트) 인터럽트 또한 자주 일어날 수 있기에,
SIMD 레지스터 값들을 메모리에 백업하고 또 다시 복구하는 과정은 커널의 성능을 심각하게 해칠 수 있습니다. 이를 피하기 위해 커널이 SIMD 명령어를 사용하지 않도록 설정합니다 (물론 우리의 커널 위에서 구동할 프로그램들은 SIMD 명령어들을 사용할 수 있습니다!).
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
`x86_64` 환경에서 SIMD 기능을 비활성화하는 것에는 걸림돌이 하나 있는데, 그것은 바로 `x86_64` 환경에서 부동소수점 계산 시 기본적으로 SIMD 레지스터가 사용된다는 것입니다. 이 문제를 해결하기 위해 `soft-float` 기능 (일반 정수 계산만을 이용해 부동소수점 계산을 소프트웨어 단에서 모방)을 활성화시킵니다.
더 자세히 알고 싶으시다면, 저희가 작성한 [SIMD 기능 해제](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.ko.md)에 관한 포스트를 확인해주세요.
#### 요약
컴파일 대상 환경 설정 파일을 아래와 같이 작성합니다:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### 커널 빌드하기
우리가 정의한 새로운 컴파일 대상 환경을 목표로 컴파일할 때에 리눅스 시스템의 관례를 따를 것입니다 (LLVM이 기본적으로 리눅스 시스템 관례를 따르기에 그렇습니다). 즉, [지난 포스트][previous post]에서 설명한 것처럼 우리는 실행 시작 지점의 이름을 `_start`로 지정할 것입니다:
[previous post]: @/edition-2/posts/01-freestanding-rust-binary/index.md
```rust
// src/main.rs
#![no_std] // Rust 표준 라이브러리를 링크하지 않도록 합니다
#![no_main] // Rust 언어에서 사용하는 실행 시작 지점 (main 함수)을 사용하지 않습니다
use core::panic::PanicInfo;
/// 패닉이 일어날 경우, 이 함수가 호출됩니다.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // 이 함수의 이름을 mangle하지 않습니다
pub extern "C" fn _start() -> ! {
// 링커는 기본적으로 '_start' 라는 이름을 가진 함수를 실행 시작 지점으로 삼기에,
// 이 함수는 실행 시작 지점이 됩니다
loop {}
}
```
호스트 운영체제에 관계 없이 실행 시작 지점 함수의 이름은 `_start`로 지정해야 함을 기억해주세요.
이제 `--target` 인자를 통해 위에서 다룬 JSON 파일의 이름을 전달하여 우리가 정의한 새로운 컴파일 대상 환경을 목표로 커널을 빌드할 수 있습니다:
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
실패하였군요! 이 오류는 Rust 컴파일러가 더 이상 [`core` 라이브러리][`core` library]를 찾지 못한다는 것을 알려줍니다. 이 라이브러리는 `Result``Option` 그리고 반복자 등 Rust의 기본적인 타입들을 포함하며, 모든 `no_std` 크레이트에 암시적으로 링크됩니다.
[`core` library]: https://doc.rust-lang.org/nightly/core/index.html
문제는 core 라이브러리가 _미리 컴파일된 상태_ 의 라이브러리로 Rust 컴파일러와 함께 배포된다는 것입니다. `x86_64-unknown-linux-gnu` 등 배포된 라이브러리가 지원하는 컴파일 목표 환경을 위해 빌드하는 경우 문제가 없지만, 우리가 정의한 커스텀 환경을 위해 빌드하는 경우에는 라이브러리를 이용할 수 없습니다. 기본적으로 지원되지 않는 새로운 시스템 환경을 위해 코드를 빌드하기 위해서는 새로운 시스템 환경에서 구동 가능하도록 `core` 라이브러리를 새롭게 빌드해야 합니다.
#### `build-std` 기능
이제 cargo의 [`build-std 기능`][`build-std` feature]이 필요한 시점이 왔습니다. Rust 언어 설치파일에 함께 배포된 `core` 및 다른 표준 라이브러리 크레이트 버전을 사용하는 대신, 이 기능을 이용하여 해당 크레이트들을 직접 재컴파일하여 사용할 수 있습니다. 이 기능은 아직 비교적 새로운 기능이며 아직 완성된 기능이 아니기에, "unstable" 한 기능으로 표기되며 [nightly 버전의 Rust 컴파일러][nightly Rust compilers]에서만 이용가능합니다.
[`build-std` feature]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[nightly Rust compilers]: #installing-rust-nightly
해당 기능을 사용하려면, [cargo 설정][cargo configuration] 파일 `.cargo/config.toml`을 아래와 같이 만들어야 합니다:
```toml
# .cargo/config.toml 에 들어갈 내용
[unstable]
build-std = ["core", "compiler_builtins"]
```
위 설정은 cargo에게 `core``compiler_builtins` 라이브러리를 새로 컴파일하도록 지시합니다. `compiler_builtins``core`가 사용하는 라이브러리입니다. 해당 라이브러리들의 소스 코드가 있어야 새로 컴파일할 수 있기에, `rustup component add rust-src` 명령어를 통해 소스 코드를 설치합니다.
<div class="note">
**주의:** `unstable.build-std` 설정 키를 이용하려면 2020-07-15 혹은 그 이후에 출시된 Rust nightly 버전을 사용하셔야 합니다.
</div>
cargo 설정 키 `unstable.build-std`를 설정하고 `rust-src` 컴포넌트를 설치한 후에 다시 빌드 명령어를 실행합니다:
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
이제 `cargo build` 명령어가 `core`, `rustc-std-workspace-cord` (`compiler_builtins`가 필요로 하는 라이브러리) 그리고 `compiler_builtins` 라이브러리를 우리의 커스텀 컴파일 대상을 위해 다시 컴파일하는 것을 확인할 수 있습니다.
#### 메모리 관련 내장 함수
Rust 컴파일러는 특정 군의 내장 함수들이 (built-in function) 모든 시스템에서 주어진다고 가정합니다. 대부분의 내장 함수들은 우리가 방금 컴파일한 `compiler_builtins` 크레이트가 이미 갖추고 있습니다. 하지만 그중 몇몇 메모리 관련 함수들은 기본적으로 사용 해제 상태가 되어 있는데, 그 이유는 해당 함수들을 호스트 시스템의 C 라이브러리가 제공하는 것이 관례이기 때문입니다. `memset`(메모리 블럭 전체에 특정 값 저장하기), `memcpy` (한 메모리 블럭의 데이터를 다른 메모리 블럭에 옮겨쓰기), `memcmp` (메모리 블럭 두 개의 데이터를 비교하기) 등이 이 분류에 해당합니다. 여태까지는 우리가 이 함수들 중 어느 하나도 사용하지 않았지만, 운영체제 구현을 더 추가하다 보면 필수적으로 사용될 함수들입니다 (예를 들어, 구조체를 복사하여 다른 곳에 저장할 때).
우리는 운영체제의 C 라이브러리를 링크할 수 없기에, 다른 방식으로 이러한 내장 함수들을 컴파일러에 전달해야 합니다. 한 방법은 우리가 직접 `memset` 등의 내장함수들을 구현하고 컴파일 과정에서 함수명이 바뀌지 않도록 `#[no_mangle]` 속성을 적용하는 것입니다. 하지만 이 방법의 경우 우리가 직접 구현한 함수 로직에 아주 작은 실수만 있어도 undefined behavior를 일으킬 수 있기에 위험합니다. 예를 들어 `memcpy`를 구현하는 데에 `for`문을 사용한다면 무한 재귀 루프가 발생할 수 있는데, 그 이유는 `for`문의 구현이 내부적으로 trait 함수인 [`IntoIterator::into_iter`]를 호출하고 이 함수가 다시 `memcpy` 를 호출할 수 있기 때문입니다. 그렇기에 충분히 검증된 기존의 구현 중 하나를 사용하는 것이 바람직합니다.
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
다행히도 `compiler_builtins` 크레이트가 이미 필요한 내장함수 구현을 전부 갖추고 있으며, C 라이브러리에서 오는 내장함수 구현과 충돌하지 않도록 사용 해제되어 있었던 것 뿐입니다. cargo의 [`build-std-features`] 플래그를 `["compiler-builtins-mem"]`으로 설정함으로써 `compiler_builtins`에 포함된 내장함수 구현을 사용할 수 있습니다. `build-std` 플래그와 유사하게 이 플래그 역시 커맨드 라인에서 `-Z` 플래그를 이용해 인자로 전달하거나 `.cargo/config.toml``[unstable]` 테이블에서 설정할 수 있습니다. 우리는 매번 이 플래그를 사용하여 빌드할 예정이기에 `.cargo/config.toml`을 통해 설정을 하는 것이 장기적으로 더 편리할 것입니다:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# .cargo/config.toml 에 들어갈 내용
[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "compiler_builtins"]
```
(`compiler-builtins-mem` 기능에 대한 지원이 [굉장히 최근에 추가되었기에](https://github.com/rust-lang/rust/pull/77284), Rust nightly `2020-09-30` 이상의 버전을 사용하셔야 합니다.)
이 기능은 `compiler_builtins` 크레이트의 [`mem` 기능 (feature)][`mem` feature]를 활성화 시킵니다. 이는 `#[no_mangle]` 속성이 [`memcpy` 등의 함수 구현][`memcpy` etc. implementations]에 적용되게 하여 링크가 해당 함수들을 식별하고 사용할 수 있게 합니다.
[`mem` feature]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L54-L55
[`memcpy` etc. implementations]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
이제 우리의 커널은 컴파일러가 요구하는 함수들에 대한 유효한 구현을 모두 갖추게 되었기에, 커널 코드가 더 복잡해지더라도 상관 없이 컴파일하는 데에 문제가 없을 것입니다.
#### 기본 컴파일 대상 환경 설정하기
기본 컴파일 대상 환경을 지정하여 설정해놓으면 `cargo build` 명령어를 실행할 때마다 `--target` 인자를 넘기지 않아도 됩니다. [cargo 설정][cargo configuration] 파일인 `.cargo/config.toml`에 아래의 내용을 추가해주세요:
[cargo configuration]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# .cargo/config.toml 에 들어갈 내용
[build]
target = "x86_64-blog_os.json"
```
이로써 `cargo`는 명시적으로 `--target` 인자가 주어지지 않으면 `x86_64-blog_os.json`에 명시된 컴파일 대상 환경을 기본 값으로 이용합니다. `cargo build` 만으로 간단히 커널을 빌드할 수 있게 되었습니다. cargo 설정 옵션들에 대해 더 자세한 정보를 원하시면 [공식 문서][cargo configuration]을 확인해주세요.
`cargo build`만으로 이제 bare metal 환경을 목표로 커널을 빌드할 수 있지만, 아직 실행 시작 지점 함수 `_start`는 텅 비어 있습니다.
이제 이 함수에 코드를 추가하여 화면에 메세지를 출력해볼 것입니다.
### 화면에 출력하기
현재 단계에서 가장 쉽게 화면에 문자를 출력할 수 있는 방법은 바로 [VGA 텍스트 버퍼][VGA text buffer]를 이용하는 것입니다. 이것은 VGA 하드웨어에 매핑되는 특수한 메모리 영역이며 화면에 출력될 내용이 저장됩니다. 주로 이 버퍼는 주로 25행 80열 (행마다 80개의 문자 저장)로 구성됩니다. 각 문자는 ASCII 문자로서 전경색 혹은 배경색과 함께 화면에 출력됩니다. 화면 출력 결과의 모습은 아래와 같습니다:
[VGA text buffer]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
![ASCII 문자들을 출력한 화면의 모습](https://upload.wikimedia.org/wikipedia/commons/f/f8/Codepage-437.png)
VGA 버퍼가 정확히 어떤 구조를 하고 있는지는 다음 포스트에서 VGA 버퍼 드라이버를 작성하면서 다룰 것입니다. "Hello World!" 메시지를 출력하는 데에는 그저 버퍼의 시작 주소가 `0xb8000`이라는 것, 그리고 각 문자는 ASCII 문자를 위한 1바이트와 색상 표기를 위한 1바이트가 필요하다는 것만 알면 충분합니다.
코드 구현은 아래와 같습니다:
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
우선 정수 `0xb8000`을 [raw 포인터][raw pointer]로 형변환 합니다. 그 다음 [static (정적 변수)][static] [바이트 문자열][byte string] `HELLO`의 반복자를 통해 각 바이트를 읽고, [`enumerate`] 함수를 통해 각 바이트의 문자열 내에서의 인덱스 값 `i`를 얻습니다. for문의 내부에서는 [`offset`] 함수를 통해 VGA 버퍼에 문자열의 각 바이트 및 색상 코드를 저장합니다 (`0xb`: light cyan 색상 코드).
[iterate]: https://doc.rust-lang.org/stable/book/ch13-02-iterators.html
[static]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
[byte string]: https://doc.rust-lang.org/reference/tokens.html#byte-string-literals
[raw pointer]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
메모리 쓰기 작업을 위한 코드 주변에 [`unsafe`] 블록이 있는 것에 주목해주세요. 여기서 `unsafe` 블록이 필요한 이유는 Rust 컴파일러가 우리가 만든 raw 포인터가 유효한 포인터인지 검증할 능력이 없기 때문입니다. `unsafe` 블록 안에 포인터에 대한 쓰기 작업 코드를 적음으로써, 우리는 컴파일러에게 해당 메모리 쓰기 작업이 확실히 안전하다고 선언한 것입니다. `unsafe` 블록이 Rust의 모든 안전성 체크를 해제하는 것은 아니며, `unsafe` 블록 안에서만 [다섯 가지 작업들을 추가적으로][five additional things] 할 수 있습니다.
[`unsafe`]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html
[five additional things]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#unsafe-superpowers
**이런 식의 Rust 코드를 작성하는 것은 절대 바람직하지 않다는 것을 강조드립니다!** unsafe 블록 안에서 raw pointer를 쓰다보면 메모리 버퍼 크기를 넘어선 메모리 주소에 데이터를 저장하는 등의 실수를 범하기 매우 쉽습니다.
그렇기에 `unsafe` 블록의 사용을 최소화하는 것이 바람직하며, 그렇게 하기 위해 Rust에서 우리는 안전한 추상 계층을 만들어 이용할 수 있습니다. 예를 들어, 모든 위험한 요소들을 전부 캡슐화한 VGA 버퍼 타입을 만들어 외부 사용자가 해당 타입을 사용 중에 메모리 안전성을 해칠 가능성을 _원천 차단_ 할 수 있습니다. 이런 설계를 통해 최소한의 `unsafe` 블록만을 사용하면서 동시에 우리가 [메모리 안전성][memory safety]을 해치는 일이 없을 것이라 자신할 수 있습니다. 이러한 안전한 추상 레벨을 더한 VGA 버퍼 타입은 다음 포스트에서 만들게 될 것입니다.
[memory safety]: https://en.wikipedia.org/wiki/Memory_safety
## 커널 실행시키기
이제 우리가 얻은 실행 파일을 실행시켜볼 차례입니다. 우선 컴파일 완료된 커널을 부트로더와 링크하여 부팅 가능한 디스크 이미지를 만들어야 합니다. 그 다음에 해당 디스크 이미지를 QEMU 가상머신에서 실행시키거나 USB 드라이브를 이용해 실제 컴퓨터에서 부팅할 수 있습니다.
### 부팅 가능한 디스크 이미지 만들기
부팅 가능한 디스크 이미지를 만들기 위해서는 컴파일된 커널을 부트로더와 링크해야합니다. [부팅에 대한 섹션][section about booting]에서 알아봤듯이, 부트로더는 CPU를 초기화하고 커널을 불러오는 역할을 합니다.
[section about booting]: #the-boot-process
우리는 부트로더를 직접 작성하는 대신에 [`bootloader`] 크레이트를 사용할 것입니다. 이 크레이트는 Rust와 인라인 어셈블리만으로 간단한 BIOS 부트로더를 구현합니다. 운영체제 커널을 부팅하는 데에 이 크레이트를 쓰기 위해 의존 크레이트 목록에 추가해줍니다:
[`bootloader`]: https://crates.io/crates/bootloader
```toml
# Cargo.toml 에 들어갈 내용
[dependencies]
bootloader = "0.9.23"
```
부트로더를 의존 크레이트로 추가하는 것만으로는 부팅 가능한 디스크 이미지를 만들 수 없습니다. 커널 컴파일이 끝난 후 커널을 부트로더와 함께 링크할 수 있어야 하는데, cargo는 현재 [빌드 직후 스크립트 실행][post-build scripts] 기능을 지원하지 않습니다.
[post-build scripts]: https://github.com/rust-lang/cargo/issues/545
이 문제를 해결하기 위해 저희가 `bootimage` 라는 도구를 만들었습니다. 이 도구는 커널과 부트로더를 각각 컴파일 한 이후에 둘을 링크하여 부팅 가능한 디스크 이미지를 생성해줍니다. 이 도구를 설치하려면 터미널에서 아래의 명령어를 실행해주세요.
```
cargo install bootimage
```
`bootimage` 도구를 실행시키고 부트로더를 빌드하려면 `llvm-tools-preview` 라는 rustup 컴포넌트가 필요합니다. 명령어 `rustup component add llvm-tools-preview`를 통해 해당 컴포넌트를 설치합니다.
`bootimage` 도구를 설치하고 `llvm-tools-preview` 컴포넌트를 추가하셨다면, 이제 아래의 명령어를 통해 부팅 가능한 디스크 이미지를 만들 수 있습니다:
```
> cargo bootimage
```
이 도구가 `cargo build`를 통해 커널을 다시 컴파일한다는 것을 확인하셨을 것입니다. 덕분에 커널 코드가 변경되어도 `cargo bootimage` 명령어 만으로도 해당 변경 사항이 바로 빌드에 반영됩니다. 그 다음 단계로 이 도구가 부트로더를 컴파일 할 것인데, 시간이 제법 걸릴 수 있습니다. 일반적인 의존 크레이트들과 마찬가지로 한 번 빌드한 후에 빌드 결과가 캐시(cache)되기 때문에, 두 번째 빌드부터는 소요 시간이 훨씬 적습니다. 마지막 단계로 `bootimage` 도구가 부트로더와 커널을 하나로 합쳐 부팅 가능한 디스크 이미지를 생성합니다.
명령어 실행이 끝난 후, `target/x86_64-blog_os/debug` 디렉토리에 `bootimage-blog_os.bin`이라는 부팅 가능한 디스크 이미지가 생성되어 있을 것입니다. 이것을 가상머신에서 부팅하거나 USB 드라이브에 복사한 뒤 실제 컴퓨터에서 부팅할 수 있습니다 (우리가 만든 디스크 이미지는 CD 이미지와는 파일 형식이 다르기 때문에 CD에 복사해서 부팅하실 수는 없습니다).
#### 어떻게 동작하는 걸까요?
`bootimage` 도구는 아래의 작업들을 순서대로 진행합니다:
- 커널을 컴파일하여 [ELF] 파일 생성
- 부트로더 크레이트를 독립된 실행파일로서 컴파일
- 커널의 ELF 파일을 부트로더에 링크
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[rust-osdev/bootloader]: https://github.com/rust-osdev/bootloader
부팅이 시작되면, 부트로더는 커널의 ELF 파일을 읽고 파싱합니다. 그 다음 프로그램의 세그먼트들을 페이지 테이블의 가상 주소에 매핑하고, `bss` 섹션의 모든 메모리 값을 0으로 초기화하며, 스택을 초기화합니다. 마지막으로, 프로그램 실행 시작 지점의 주소 (`_start` 함수의 주소)에서 제어 흐름이 계속되도록 점프합니다.
### QEMU에서 커널 부팅하기
이제 우리의 커널 디스크 이미지를 가상 머신에서 부팅할 수 있습니다. [QEMU]에서 부팅하려면 아래의 명령어를 실행하세요:
[QEMU]: https://www.qemu.org/
```
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]
```
위 명령어를 실행하면 아래와 같은 새로운 창이 열릴 것입니다:
![QEMU showing "Hello World!"](qemu.png)
화면에 "Hello World!" 메세지가 출력된 것을 확인하실 수 있습니다.
### 실제 컴퓨터에서 부팅하기
USB 드라이브에 우리의 커널을 저장한 후 실제 컴퓨터에서 부팅하는 것도 가능합니다:
```
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
`sdX` 대신 여러분이 소지한 USB 드라이브의 기기명을 입력하시면 됩니다. 해당 기기에 쓰인 데이터는 전부 덮어씌워지기 때문에 정확한 기기명을 입력하도록 주의해주세요.
이미지를 USB 드라이브에 다 덮어썼다면, 이제 실제 하드웨어에서 해당 이미지를 통해 부트하여 실행할 수 있습니다. 아마 특별한 부팅 메뉴를 사용하거나 BIOS 설정에서 부팅 순서를 변경하여 USB로부터 부팅하도록 설정해야 할 것입니다. `bootloader` 크레이트가 아직 UEFI를 지원하지 않기에, UEFI 표준을 사용하는 기기에서는 부팅할 수 없습니다.
### `cargo run` 명령어 사용하기
QEMU에서 커널을 쉽게 실행할 수 있게 아래처럼 `runner`라는 새로운 cargo 설정 키 값을 추가합니다.
```toml
# .cargo/config.toml 에 들어갈 내용
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
`target.'cfg(target_os = "none")'`가 붙은 키 값은 `"os"` 필드 설정이 `"none"`으로 되어 있는 컴파일 대상 환경에만 적용됩니다. 따라서 우리의 `x86_64-blog_os.json` 또한 적용 대상에 포함됩니다. `runner` 키 값은 `cargo run` 명령어 실행 시 어떤 명령어를 실행할지 지정합니다. 빌드가 성공적으로 끝난 후에 `runner` 키 값의 명령어가 실행됩니다. [cargo 공식 문서][cargo configuration]를 통해 더 자세한 내용을 확인하실 수 있습니다.
명령어 `bootimage runner`는 프로젝트의 부트로더 라이브러리를 링크한 후에 QEMU를 실행시킵니다.
그렇기에 일반적인 `runner` 실행파일을 실행하듯이 `bootimage runner` 명령어를 사용하실 수 있습니다. [`bootimage` 도구의 Readme 문서][Readme of `bootimage`]를 통해 더 자세한 내용 및 다른 가능한 설정 옵션들을 확인하세요.
[Readme of `bootimage`]: https://github.com/rust-osdev/bootimage
이제 `cargo run` 명령어를 통해 우리의 커널을 컴파일하고 QEMU에서 부팅할 수 있습니다.
## 다음 단계는 무엇일까요?
다음 글에서는 VGA 텍스트 버퍼 (text buffer)에 대해 더 알아보고 VGA text buffer와 안전하게 상호작용할 수 있는 방법을 구현할 것입니다.
또한 `println` 매크로를 사용할 수 있도록 기능을 추가할 것입니다.

View File

@@ -1,502 +0,0 @@
+++
title = "Минимально возможное ядро на Rust"
weight = 2
path = "ru/minimal-rust-kernel"
date = 2018-02-10
[extra]
chapter = "С нуля"
translators = ["MrZloHex"]
+++
В этом посте мы создадим минимальное 64-битное ядро на Rust для архитектуры x86_64. Мы будем отталкиваться от [независимого бинарного файла][freestanding Rust binary] из предыдущего поста для создания загрузочного образа диска, который может что-то выводить на экран.
[freestanding Rust binary]: @/edition-2/posts/01-freestanding-rust-binary/index.ru.md
<!-- more -->
Этот блог открыто разрабатывается на [GitHub]. Если у вас возникли какие-либо проблемы или вопросы, пожалуйста, создайте _issue_. Также вы можете оставлять комментарии [в конце страницы][at the bottom]. Полный исходный код для этого поста вы можете найти в репозитории в ветке [`post-02`][post branch].
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## Последовательность процессов запуска {#the-boot-process}
Когда вы включаете компьютер, он начинает выполнять код микропрограммы, который хранится в [ПЗУ][ROM] материнской платы. Этот код выполняет [самотестирование при включении][power-on self-test], определяет доступную оперативную память и выполняет предварительную инициализацию процессора и аппаратного обеспечения. После этого он ищет загрузочный диск и начинает загрузку ядра операционной системы.
[ROM]: https://en.wikipedia.org/wiki/Read-only_memory
[power-on self-test]: https://en.wikipedia.org/wiki/Power-on_self-test
Для архитектуры x86 существует два стандарта прошивки: “Basic Input/Output System“ ("Базовая система ввода/вывода" **[BIOS]**) и более новый “Unified Extensible Firmware Interface” ("Унифицированный расширяемый интерфейс прошивки" **[UEFI]**). Стандарт BIOS - старый, но простой и хорошо поддерживаемый на любой машине x86 с 1980-х годов. UEFI, напротив, более современный и имеет гораздо больше возможностей, но более сложен в настройке (по крайней мере, на мой взгляд).
[BIOS]: https://en.wikipedia.org/wiki/BIOS
[UEFI]: https://en.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface
В данный момент, мы обеспечиваем поддержку только BIOS, но планируется поддержка и UEFI. Если вы хотите помочь нам в этом, обратитесь к [Github issue](https://github.com/phil-opp/blog_os/issues/349).
## Запуск BIOS
Почти все системы x86 имеют поддержку загрузки BIOS, включая более новые машины на базе UEFI, которые используют эмулированный BIOS. Это замечательно, потому что вы можете использовать одну и ту же логику загрузки на всех машинах из прошлых веков. Но такая широкая совместимость одновременно является и самым большим недостатком загрузки BIOS, поскольку это означает, что перед загрузкой процессор переводится в 16-битный режим совместимости под названием [реальный режим], чтобы архаичные загрузчики 1980-х годов все еще работали.
Но давайте начнем с самого начала:
Когда вы включаете компьютер, он загружает BIOS из специальной флэш-памяти, расположенной на материнской плате. BIOS запускает процедуры самодиагностики и инициализации оборудования, затем ищет загрузочные диски. Если он находит такой, управление передается _загрузчику_, который представляет собой 512-байтовую порцию исполняемого кода, хранящуюся в начале диска. Большинство загрузчиков имеют размер более 512 байт, поэтому загрузчики обычно разделяются на небольшой первый этап, который помещается в 512 байт, и второй этап, который впоследствии загружается первым этапом.
Загрузчик должен определить расположение образа ядра на диске и загрузить его в память. Он также должен переключить процессор из 16-битного [реального режима][real mode] сначала в 32-битный [защищенный режим][protected mode], а затем в 64-битный [длинный режим][long mode], где доступны 64-битные регистры и вся основная память. Третья задача - запросить определенную информацию (например, карту памяти) у BIOS и передать ее ядру ОС.
[real mode]: https://en.wikipedia.org/wiki/Real_mode
[protected mode]: https://en.wikipedia.org/wiki/Protected_mode
[long mode]: https://en.wikipedia.org/wiki/Long_mode
[memory segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
Написание загрузчика немного громоздко, поскольку требует использования языка ассемблера и множества неинтересных действий, таких как "запишите это магическое значение в этот регистр процессора". Поэтому мы не рассматриваем создание загрузчика в этом посте и вместо этого предоставляем инструмент под названием [bootimage], который автоматически добавляет загрузчик к вашему ядру.
[bootimage]: https://github.com/rust-osdev/bootimage
Если вы заинтересованы в создании собственного загрузчика: Оставайтесь с нами, набор постов на эту тему уже запланирован! <!-- , check out our “_[Writing a Bootloader]_” posts, where we explain in detail how a bootloader is built. -->
#### Стандарт Multiboot
Чтобы избежать того, что каждая операционная система реализует свой собственный загрузчик, который совместим только с одной ОС, [Free Software Foundation] в 1995 году создал открытый стандарт загрузчика под названием [Multiboot]. Стандарт определяет интерфейс между загрузчиком и операционной системой, так что любой совместимый с Multiboot загрузчик может загружать любую совместимую с Multiboot операционную систему. Эталонной реализацией является [GNU GRUB], который является самым популярным загрузчиком для систем Linux.
[Free Software Foundation]: https://en.wikipedia.org/wiki/Free_Software_Foundation
[Multiboot]: https://wiki.osdev.org/Multiboot
[GNU GRUB]: https://en.wikipedia.org/wiki/GNU_GRUB
Чтобы сделать ядро совместимым с Multiboot, нужно просто вставить так называемый [Multiboot заголовок][Multiboot header] в начало файла ядра. Это делает загрузку ОС в GRUB очень простой. Однако у GRUB и стандарта Multiboot есть и некоторые проблемы:
[Multiboot header]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format
- Они поддерживают только 32-битный защищенный режим. Это означает, что для перехода на 64-битный длинный режим необходимо выполнить конфигурацию процессора.
- Они предназначены для того, чтобы упростить загрузчик вместо ядра. Например, ядро должно быть связано с [скорректированным размером страницы по умолчанию][adjusted default page size], потому что иначе GRUB не сможет найти заголовок Multiboot. Другой пример - [информация запуска][boot information], которая передается ядру, содержит множество структур, зависящих от архитектуры, вместо того, чтобы предоставлять чистые абстракции.
- И GRUB, и стандарт Multiboot документированы очень скудно.
- GRUB должен быть установлен на хост-системе, чтобы создать загрузочный образ диска из файла ядра. Это усложняет разработку под Windows или Mac.
[adjusted default page size]: https://wiki.osdev.org/Multiboot#Multiboot_2
[boot information]: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format
Из-за этих недостатков мы решили не использовать GRUB или стандарт Multiboot. Однако мы планируем добавить поддержку Multiboot в наш инструмент [bootimage], чтобы можно было загружать ваше ядро и на системе GRUB. Если вы заинтересованы в написании ядра, совместимого с Multiboot, ознакомьтесь с [первым выпуском][first edition] этой серии блогов.
[first edition]: @/edition-1/_index.md
### UEFI
(На данный момент мы не предоставляем поддержку UEFI, но мы бы хотели! Если вы хотите помочь, пожалуйста, сообщите нам об этом в [Github issue](https://github.com/phil-opp/blog_os/issues/349).)
## Минимально возможное ядро
Теперь, когда мы примерно знаем, как запускается компьютер, пришло время создать собственное минимально возможное ядро. Наша цель - создать образ диска, который при загрузке выводит на экран "Hello World!". Для этого мы будем используем [Независимый бинарный файл на Rust][freestanding Rust binary] из предыдущего поста.
Как вы помните, мы собирали независимый бинарный файл с помощью `cargo`, но в зависимости от операционной системы нам требовались разные имена точек входа и флаги компиляции. Это потому, что `cargo` по умолчанию компилирует для _хостовой системы_, то есть системы, на которой вы работаете. Это не то, что мы хотим для нашего ядра, потому что ядро, работающее поверх, например, Windows, не имеет особого смысла. Вместо этого мы хотим компилировать для четко определенной _целевой системы_.
### Установка Rust Nightly {#installing-rust-nightly}
Rust имеет три релизных канала: _stable_, _beta_ и _nightly_. В книге Rust Book очень хорошо объясняется разница между этими каналами, поэтому уделите минуту и [ознакомьтесь с ней](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html#choo-choo-release-channels-and-riding-the-trains). Для создания операционной системы нам понадобятся некоторые экспериментальные возможности, которые доступны только на канале nightly, поэтому нам нужно установить nightly версию Rust.
Для управления установками Rust я настоятельно рекомендую [rustup]. Он позволяет устанавливать nightly, beta и stable компиляторы рядом друг с другом и облегчает их обновление. С помощью rustup вы можете использовать nightly компилятор для текущего каталога, выполнив команду `rustup override set nightly`. В качестве альтернативы вы можете добавить файл `rust-toolchain` с содержимым `nightly` в корневой каталог проекта. Вы можете проверить, установлена ли у вас версия nightly, выполнив команду `rustc --version`: Номер версии должен содержать `-nightly` в конце.
[rustup]: https://www.rustup.rs/
Nightly версия компилятора позволяет нам подключать различные экспериментальные возможности с помощью так называемых _флагов_ в верхней части нашего файла. Например, мы можем включить экспериментальный [макрос `asm!``asm!` macro] для встроенного ассемблера, добавив `#![feature(asm)]` в начало нашего `main.rs`. Обратите внимание, что такие экспериментальные возможности совершенно нестабильны, что означает, что будущие версии Rust могут изменить или удалить их без предварительного предупреждения. По этой причине мы будем использовать их только в случае крайней необходимости.
[`asm!` macro]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### Спецификация целевой платформы
Cargo поддерживает различные целевые системы через параметр `--target`. Цель описывается так называемой тройкой _[target triple]_, которая описывает архитектуру процессора, производителя, операционную систему и [ABI]. Например, тройка целей `x86_64-unknown-linux-gnu` описывает систему с процессором `x86_64`, неизвестным поставщиком и операционной системой Linux с GNU ABI. Rust поддерживает [множество различных целевых троек][platform-support], включая `arm-linux-androideabi` для Android или [`wasm32-unknown-unknown` для WebAssembly](https://www.hellorust.com/setup/wasm-target/).
[target triple]: https://clang.llvm.org/docs/CrossCompilation.html#target-triple
[ABI]: https://stackoverflow.com/a/2456882
[platform-support]: https://forge.rust-lang.org/release/platform-support.html
[custom-targets]: https://doc.rust-lang.org/nightly/rustc/targets/custom.html
Однако для нашей целевой системы нам требуются некоторые специальные параметры конфигурации (например, отсутствие базовой ОС), поэтому ни одна из [существующих целевых троек][platform-support] не подходит. К счастью, Rust позволяет нам определить [custom target][custom-targets] через JSON-файл. Например, JSON-файл, описывающий цель `x86_64-unknown-linux-gnu`, выглядит следующим образом:
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
Большинство полей требуется LLVM для генерации кода для данной платформы. Например, поле [`data-layout`] определяет размер различных типов целых чисел, чисел с плавающей точкой и указателей. Затем есть поля, которые Rust использует для условной компиляции, такие как `target-pointer-width`. Третий вид полей определяет, как должен быть собран крейт. Например, поле `pre-link-args` определяет аргументы, передаваемые [компоновщику][linker].
[`data-layout`]: https://llvm.org/docs/LangRef.html#data-layout
[linker]: https://en.wikipedia.org/wiki/Linker_(computing)
Для нашего ядра тоже нужна архитектура `x86_64`, поэтому наша спецификация цели будет очень похожа на приведенную выше. Начнем с создания файла `x86_64-blog_os.json` (выберите любое имя, которое вам нравится) с общим содержанием:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
Обратите внимание, что мы изменили ОС в поле `llvm-target` и `os` на `none`, потому что мы будем работать на голом железе.
Добавляем дополнительные параметры для сборки ядра:
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
Вместо того чтобы использовать компоновщик по умолчанию платформы (который может не поддерживать цели Linux), мы используем кроссплатформенный компоновщик [LLD], поставляемый вместе с Rust, для компоновки нашего ядра.
[LLD]: https://lld.llvm.org/
```json
"panic-strategy": "abort",
```
Этот параметр указывает, что цель не поддерживает [раскрутку стека][stack unwinding] при панике, поэтому вместо этого программа должна прерваться напрямую. Это имеет тот же эффект, что и опция `panic = "abort"` в нашем Cargo.toml, поэтому мы можем удалить ее оттуда. (Обратите внимание, что в отличие от опции Cargo.toml, эта опция также будет применяться, когда мы перекомпилируем библиотеку `core` позже в этом посте. Поэтому не забудьте добавить эту опцию, даже если вы предпочтете оставить опцию в Cargo.toml).
[stack unwinding]: https://www.bogotobogo.com/cplusplus/stackunwinding.php
```json
"disable-redzone": true,
```
Мы пишем ядро, поэтому в какой-то момент нам понадобится обрабатывать прерывания. Чтобы сделать это безопасно, мы должны отключить определенную оптимизацию указателя стека, называемую _"красной зоной"_, поскольку в противном случае она приведет к повреждениям стека. Для получения дополнительной информации см. нашу отдельную статью об [отключении красной зоны][disabling the red zone].
[disabling the red zone]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.ru.md
```json
"features": "-mmx,-sse,+soft-float",
```
Поле `features` включает/выключает функции целевой платформы. Мы отключаем функции `mmx` и `sse`, добавляя к ним минус, и включаем функцию `soft-float`, добавляя к ней плюс. Обратите внимание, что между разными флагами не должно быть пробелов, иначе LLVM не сможет интерпретировать строку features.
Функции `mmx` и `sse` определяют поддержку инструкций [Single Instruction Multiple Data (SIMD)], которые часто могут значительно ускорить работу программ. Однако использование больших регистров SIMD в ядрах ОС приводит к проблемам с производительностью. Причина в том, что ядру необходимо восстановить все регистры в исходное состояние перед продолжением прерванной программы. Это означает, что ядро должно сохранять полное состояние SIMD в основной памяти при каждом системном вызове или аппаратном прерывании. Поскольку состояние SIMD очень велико (512-1600 байт), а прерывания могут происходить очень часто, эти дополнительные операции сохранения/восстановления значительно снижают производительность. Чтобы избежать этого, мы отключили SIMD для нашего ядра (не для приложений, работающих поверх него!).
[Single Instruction Multiple Data (SIMD)]: https://en.wikipedia.org/wiki/SIMD
Проблема с отключением SIMD заключается в том, что операции с числами с плавающей точкой на `x86_64` по умолчанию требуют регистров SIMD. Чтобы решить эту проблему, мы добавили функцию `soft-float`, которая эмулирует все операции с числами с плавающей точкой через программные функции, основанные на обычных целых числах.
Для получения дополнительной информации см. наш пост об [отключении SIMD](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.ru.md).
#### Соединяем все вместе
Наша спецификация целовой платформы выглядит следующим образом:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### Компиляция ядра
Компиляция для нашей новой целевой платформы будет использовать соглашения Linux (я не совсем уверен почему &mdash; предполагаю, что это просто поведение LLVM по умолчанию). Это означает, что нам нужна точка входа с именем `_start`, как описано в [предыдущем посте][previous post]:
[previous post]: @/edition-2/posts/01-freestanding-rust-binary/index.ru.md
```rust
// src/main.rs
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
// this function is the entry point, since the linker looks for a function
// named `_start` by default
loop {}
}
```
Обратите внимание, что точка входа должна называться `_start` независимо от используемой вами ОС.
Теперь мы можем собрать ядро для нашей новой цели, передав имя файла JSON в качестве `--target`:
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
Не получается! Ошибка сообщает нам, что компилятор Rust больше не может найти [библиотеку `core`][`core` library]. Эта библиотека содержит основные типы Rust, такие как `Result`, `Option` и итераторы, и неявно связана со всеми `no_std` модулями.
[`core` library]: https://doc.rust-lang.org/nightly/core/index.html
Проблема в том, что корневая (`core`) библиотека распространяется вместе с компилятором Rust как _прекомпилированная_ библиотека. Поэтому она действительна только для поддерживаемых тройных хостов (например, `x86_64-unknown-linux-gnu`), но не для нашей пользовательской целевой платформы. Если мы хотим скомпилировать код для других целевых платформ, нам нужно сначала перекомпилировать `core` для этих целей.
### Функция `build-std`
Вот тут-то и приходит на помощь функция [`build-std`][`build-std` feature] в cargo. Она позволяет перекомпилировать `core` и другие стандартные библиотеки по требованию, вместо того, чтобы использовать предварительно скомпилированные версии, поставляемые вместе с установкой Rust. Эта функция очень новая и еще не закончена, поэтому она помечена как "нестабильная" и доступна только на [nightly Rust].
[`build-std` feature]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[nightly Rust]: #installing-rust-nightly
Чтобы использовать эту функцию, нам нужно создать файл [конфигурации cargo][cargo configuration] по пути `.cargo/config.toml` со следующим содержимым:
[cargo configuration]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
```
Это говорит cargo, что он должен перекомпилировать библиотеки `core` и `compiler_builtins`. Последняя необходима, поскольку `core` зависит от неё. Чтобы перекомпилировать эти библиотеки, cargo нужен доступ к исходному коду rust, который мы можем установить с помощью команды `rustup component add rust-src`.
<div class="note">
**Note:** Ключ конфигурации `unstable.build-std` требует как минимум Rust nightly от 2020-07-15.
</div>
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
Мы видим, что `cargo build` теперь перекомпилирует библиотеки `core`, `rustc-std-workspace-core` (зависимость от `compiler_builtins`) и `compiler_builtins` для нашей пользовательской целевой платформы.
#### Внутренние функции, работающие с памятью
Компилятор Rust предполагает, что определенный набор встроенных функций доступен для всех систем. Большинство этих функций обеспечивается модулем `compiler_builtins`, который мы только что перекомпилировали. Однако в этом модуле есть некоторые функции, связанные с памятью, которые не включены по умолчанию, потому что они обычно предоставляются библиотекой C в системе. Эти функции включают `memset`, которая устанавливает все байты в блоке памяти в заданное значение, `memcpy`, которая копирует один блок памяти в другой, и `memcmp`, которая сравнивает два блока памяти. Хотя ни одна из этих функций нам сейчас не понадобилась для компиляции нашего ядра, они потребуются, как только мы добавим в него дополнительный код (например, при копировании структур).
Поскольку мы не можем ссылаться на С библиотеку хостовой операционной системы, нам нужен альтернативный способ предоставления этих функций компилятору. Одним из возможных подходов для этого может быть реализация наших собственных функций `memset` и т.д. и применение к ним атрибута `#[no_mangle]` (чтобы избежать автоматического переименования во время компиляции). Однако это опасно, поскольку малейшая ошибка в реализации этих функций может привести к неопределенному поведению. Например, при реализации `memcpy` с помощью цикла `for` вы можете получить бесконечную рекурсию, поскольку циклы `for` неявно вызывают метод трейта [`IntoIterator::into_iter`], который может снова вызвать `memcpy`. Поэтому хорошей идеей будет повторное использование существующих, хорошо протестированных реализаций.
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
К счастью, модуль `compiler_builtins` уже содержит реализации всех необходимых функций, они просто отключены по умолчанию, чтобы не столкнуться с реализациями из С библиотеки. Мы можем включить их, установив флаг cargo [`build-std-features`] на `["compiler-builtins-mem"]`. Как и флаг `build-std`, этот флаг может быть передан в командной строке как флаг `-Z` или настроен в таблице `unstable` в файле `.cargo/config.toml`. Поскольку мы всегда хотим собирать с этим флагом, вариант с конфигурационным файлом имеет для нас больше смысла:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# in .cargo/config.toml
[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "compiler_builtins"]
```
(Поддержка функции `compiler-builtins-mem` была [добавлена совсем недавно](https://github.com/rust-lang/rust/pull/77284), поэтому для нее вам нужен как минимум Rust nightly `2020-09-30`).
За кулисами этот флаг включает функцию [`mem`][`mem` feature] крейта `compiler_builtins`. Это приводит к тому, что атрибут `#[no_mangle]` применяется к [реализациям `memcpy` и т.п.][`memcpy` etc. implementations] из этого крейта, что делает их доступными для компоновщика.
[`mem` feature]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L54-L55
[`memcpy` etc. implementations]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
Благодаря этому изменению наше ядро имеет валидные реализации для всех функций, требуемых компилятором, поэтому оно будет продолжать компилироваться, даже если наш код станет сложнее.
#### Переопределение цели по умолчанию
Чтобы избежать передачи параметра `--target` при каждом вызове `cargo build`, мы можем переопределить цель по умолчанию. Для этого мы добавим следующее в наш файл [конфигураций cargo][cargo configuration] по пути `.cargo/config.toml`:
```toml
# in .cargo/config.toml
[build]
target = "x86_64-blog_os.json"
```
С этой конфигурацией `cargo` будет использовать нашу цель `x86_64-blog_os.json`, если не передан явный аргумент `--target`. Это означает, что теперь мы можем собрать наше ядро с помощью простой `cargo build`. Чтобы узнать больше о параметрах конфигурации cargo, ознакомьтесь с [официальной документацией][cargo configuration].
Теперь мы можем скомпилировать наше ядро под голое железо с помощью простой `cargo build`. Однако наша точка входа `_start`, которая будет вызываться загрузчиком, все еще пуста. Пришло время вывести что-нибудь на экран.
### Вывод на экран
Самым простым способом печати текста на экран на данном этапе является [текстовый буфер VGA][VGA text buffer]. Это специальная область памяти, сопоставленная с аппаратным обеспечением VGA, которая содержит содержимое, отображаемое на экране. Обычно он состоит из 25 строк, каждая из которых содержит 80 символьных ячеек. Каждая символьная ячейка отображает символ ASCII с некоторыми цветами переднего и заднего плана. Вывод на экран выглядит следующим образом:
[VGA text buffer]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
![screen output for common ASCII characters](https://upload.wikimedia.org/wikipedia/commons/f/f8/Codepage-437.png)
Точную разметку буфера VGA мы обсудим в следующем посте, где мы напишем первый небольшой драйвер для него. Для печати "Hello World!" нам достаточно знать, что буфер расположен по адресу `0xb8000` и что каждая символьная ячейка состоит из байта ASCII и байта цвета.
Реализация выглядит следующим образом:
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
Сначала мы приводим целое число `0xb8000` к [сырому указателю][raw pointer]. Затем мы [итерируем][iterate] по байтам [статической][static] [байтовой строки][byte string] `HELLO`. Мы используем метод [`enumerate`], чтобы дополнительно получить бегущую переменную `i`. В теле цикла for мы используем метод [`offset`] для записи байта строки и соответствующего байта цвета (`0xb` - светло-голубой).
[iterate]: https://doc.rust-lang.org/stable/book/ch13-02-iterators.html
[static]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
[`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate
[byte string]: https://doc.rust-lang.org/reference/tokens.html#byte-string-literals
[raw pointer]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
Обратите внимание, что вокруг всех записей в память стоит блок [`unsafe`]. Причина в том, что компилятор Rust не может доказать, что сырые указатели, которые мы создаем, действительны. Они могут указывать куда угодно и привести к повреждению данных. Помещая их в блок `unsafe`, мы, по сути, говорим компилятору, что абсолютно уверены в правильности операций. Обратите внимание, что блок `unsafe` не отключает проверки безопасности Rust. Он только позволяет вам делать [пять дополнительных вещей][five additional things].
[`unsafe`]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html
[five additional things]: https://doc.rust-lang.org/stable/book/ch19-01-unsafe-rust.html#unsafe-superpowers
Я хочу подчеркнуть, что **это не тот способ, которым стоит что-либо делать в Rust!** Очень легко ошибиться при работе с сырыми указателями внутри блоков `unsafe`: например, мы можем легко записать за конец буфера, если не будем осторожны.
Поэтому мы хотим минимизировать использование `unsafe` настолько, насколько это возможно. Rust дает нам возможность сделать это путем создания безопасных абстракций. Например, мы можем создать тип буфера VGA, который инкапсулирует всю небезопасность и гарантирует, что извне _невозможно_ сделать что-либо неправильно. Таким образом, нам понадобится лишь минимальное количество блоков `unsafe` и мы можем быть уверены, что не нарушаем [безопасность памяти][memory safety]. Мы создадим такую безопасную абстракцию буфера VGA в следующем посте.
[memory safety]: https://en.wikipedia.org/wiki/Memory_safety
## Запуск ядра
Теперь, когда у нас есть исполняемый файл, который делает что-то ощутимое, пришло время запустить его. Сначала нам нужно превратить наше скомпилированное ядро в загрузочный образ диска, связав его с загрузчиком. Затем мы можем запустить образ диска в виртуальной машине [QEMU] или загрузить его на реальном оборудовании с помощью USB-носителя.
### Создание загрузочного образа
Чтобы превратить наше скомпилированное ядро в загрузочный образ диска, нам нужно связать его с загрузчиком. Как мы узнали в [разделе о загрузке], загрузчик отвечает за инициализацию процессора и загрузку нашего ядра.
[разделе о загрузке]: #the-boot-process
Вместо того чтобы писать собственный загрузчик, который является самостоятельным проектом, мы используем модуль [`bootloader`]. Этот модуль реализует базовый BIOS-загрузчик без каких-либо C-зависимостей, только Rust и встроенный ассемблер. Чтобы использовать его для загрузки нашего ядра, нам нужно добавить зависимость от него:
[`bootloader`]: https://crates.io/crates/bootloader
```toml
# in Cargo.toml
[dependencies]
bootloader = "0.9.23"
```
Добавление загрузчика в качестве зависимости недостаточно для создания загрузочного образа диска. Проблема в том, что нам нужно связать наше ядро с загрузчиком после компиляции, но в cargo нет поддержки [скриптов после сборки][post-build scripts].
[post-build scripts]: https://github.com/rust-lang/cargo/issues/545
Для решения этой проблемы мы создали инструмент `bootimage`, который сначала компилирует ядро и загрузчик, а затем соединяет их вместе для создания загрузочного образа диска. Чтобы установить инструмент, выполните следующую команду в терминале:
```
cargo install bootimage
```
Для запуска `bootimage` и сборки загрузчика вам необходимо установить компонент rustup `llvm-tools-preview`. Это можно сделать, выполнив команду `rustup component add llvm-tools-preview`.
После установки `bootimage` и добавления компонента `llvm-tools-preview` мы можем создать образ загрузочного диска, выполнив команду:
```
> cargo bootimage
```
Мы видим, что инструмент перекомпилирует наше ядро с помощью `cargo build`, поэтому он автоматически подхватит все внесенные вами изменения. После этого он компилирует загрузчик, что может занять некоторое время. Как и все зависимости модулей, он собирается только один раз, а затем кэшируется, поэтому последующие сборки будут происходить гораздо быстрее. Наконец, `bootimage` объединяет загрузчик и ваше ядро в загрузочный образ диска.
После выполнения команды вы должны увидеть загрузочный образ диска с именем `bootimage-blog_os.bin` в каталоге `target/x86_64-blog_os/debug`. Вы можете загрузить его в виртуальной машине или скопировать на USB-накопитель, чтобы загрузить его на реальном оборудовании. (Обратите внимание, что это не образ CD, который имеет другой формат, поэтому запись на CD не работает).
#### Как этот работает?
Инструмент `bootimage` выполняет следующие действия за кулисами:
- Компилирует наше ядро в файл [ELF].
- Компилирует зависимость загрузчика как отдельный исполняемый файл.
- Он связывает байты ELF-файла ядра с загрузчиком.
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[rust-osdev/bootloader]: https://github.com/rust-osdev/bootloader
При запуске загрузчик читает и разбирает приложенный файл ELF. Затем он сопоставляет сегменты программы с виртуальными адресами в таблицах страниц, обнуляет секцию `.bss` и устанавливает стек. Наконец, он считывает адрес точки входа (наша функция `_start`) и переходит к ней.
### Запуск через QEMU
Теперь мы можем загрузить образ диска в виртуальной машине. Чтобы загрузить его в [QEMU], выполните следующую команду:
[QEMU]: https://www.qemu.org/
```
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]
```
Откроется отдельное окно, которое выглядит следующим образом:
![QEMU showing "Hello World!"](qemu.png)
Мы видим, что наш "Hello World!" отображается на экране.
### Настоящая машина
Также можно записать его на USB-накопитель и загрузить на реальной машине:
```
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
Где `sdX` - имя устройства вашего USB-накопителя. **Внимательно проверьте**, что вы выбрали правильное имя устройства, потому что все, что находится на этом устройстве, будет перезаписано.
После записи образа на USB-накопитель его можно запустить на реальном оборудовании, загрузившись с него. Для загрузки с USB-накопителя вам, вероятно, потребуется использовать специальное меню загрузки или изменить порядок загрузки в конфигурации BIOS. Обратите внимание, что в настоящее время это не работает на машинах с UEFI, так как модуль `bootloader` пока не имеет поддержки UEFI.
### Использование `cargo run`
Чтобы облегчить запуск нашего ядра в QEMU, мы можем установить ключ конфигурации `runner` для cargo:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
Таблица `target.'cfg(target_os = "none")'` применяется ко всем целям, которые установили поле `"os"` своего конфигурационного файла цели на `"none"`. Это включает нашу цель `x86_64-blog_os.json`. Ключ `runner` указывает команду, которая должна быть вызвана для `cargo run`. Команда запускается после успешной сборки с путем к исполняемому файлу, переданному в качестве первого аргумента. Более подробную информацию смотрите в [документации по cargo][cargo configuration].
Команда `bootimage runner` специально разработана для использования в качестве исполняемого файла `runner`. Она связывает заданный исполняемый файл с зависимостью загрузчика проекта, а затем запускает QEMU. Более подробную информацию и возможные варианты конфигурации смотрите в [Readme of `bootimage`].
[Readme of `bootimage`]: https://github.com/rust-osdev/bootimage
Теперь мы можем использовать `cargo run` для компиляции нашего ядра и его загрузки в QEMU.
## Что дальше?
В следующем посте мы более подробно рассмотрим текстовый буфер VGA и напишем безопасный интерфейс для него. Мы также добавим поддержку макроса `println`.

View File

@@ -1,443 +0,0 @@
+++
title = "最小内核"
weight = 2
path = "zh-CN/minimal-rust-kernel"
date = 2018-02-10
[extra]
# Please update this when updating the translation
translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"
# GitHub usernames of the people that translated this post
translators = ["luojia65", "Rustin-Liu", "liuyuran"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["JiangengDong"]
+++
在这篇文章中,我们将基于 **x86架构**the x86 architecture使用 Rust 语言,编写一个最小化的 64 位内核。我们将从上一章中构建的[独立式可执行程序][freestanding-rust-binary]开始,构建自己的内核;它将向显示器打印字符串,并能被打包为一个能够引导启动的**磁盘映像**disk image
[freestanding Rust binary]: @/edition-2/posts/01-freestanding-rust-binary/index.md
<!-- more -->
此博客在 [GitHub] 上公开开发. 如果您有任何问题或疑问,请在此处打开一个 issue。 您也可以在[底部][at the bottom]发表评论. 这篇文章的完整源代码可以在 [`post-02`] [post branch] 分支中找到。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-02
<!-- toc -->
## 引导启动
当我们启动电脑时,主板 [ROM](https://en.wikipedia.org/wiki/Read-only_memory)内存储的**固件**firmware将会运行它将负责电脑的**加电自检**[power-on self test](https://en.wikipedia.org/wiki/Power-on_self-test)**可用内存**available RAM的检测以及 CPU 和其它硬件的预加载。这之后,它将寻找一个**可引导的存储介质**bootable disk并开始引导启动其中的**内核**kernel
x86 架构支持两种固件标准: **BIOS**[Basic Input/Output System](https://en.wikipedia.org/wiki/BIOS))和 **UEFI**[Unified Extensible Firmware Interface](https://en.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface)。其中BIOS 标准显得陈旧而过时,但实现简单,并为 1980 年代后的所有 x86 设备所支持相反地UEFI 更现代化,功能也更全面,但开发和构建更复杂(至少从我的角度看是如此)。
在这篇文章中,我们暂时只提供 BIOS 固件的引导启动方式但是UEFI支持也已经在计划中了。如果你希望帮助我们推进它请查阅这份 [Github issue](https://github.com/phil-opp/blog_os/issues/349)。
### BIOS 启动
几乎所有的 x86 硬件系统都支持 BIOS 启动,这也包含新型的、基于 UEFI、用**模拟 BIOS**emulated BIOS的方式向后兼容的硬件系统。这可以说是一件好事情因为无论是上世纪还是现在的硬件系统你都只需编写同样的引导启动逻辑但这种兼容性有时也是 BIOS 引导启动最大的缺点,因为这意味着在系统启动前,你的 CPU 必须先进入一个 16 位系统兼容的**实模式**[real mode](https://en.wikipedia.org/wiki/Real_mode)),这样 1980 年代古老的引导固件才能够继续使用。
让我们从头开始,理解一遍 BIOS 启动的过程。
当电脑启动时,主板上特殊的闪存中存储的 BIOS 固件将被加载。BIOS 固件将会加电自检、初始化硬件,然后它将寻找一个可引导的存储介质。如果找到了,那电脑的控制权将被转交给**引导程序**bootloader一段存储在存储介质的开头的、512字节长度的程序片段。大多数的引导程序长度都大于512字节——所以通常情况下引导程序都被切分为一段优先启动、长度不超过512字节、存储在介质开头的**第一阶段引导程序**first stage bootloader和一段随后由其加载的、长度可能较长、存储在其它位置的**第二阶段引导程序**second stage bootloader
引导程序必须决定内核的位置,并将内核加载到内存。引导程序还需要将 CPU 从 16 位的实模式,先切换到 32 位的**保护模式**[protected mode](https://en.wikipedia.org/wiki/Protected_mode)),最终切换到 64 位的**长模式**[long mode](https://en.wikipedia.org/wiki/Long_mode)):此时,所有的 64 位寄存器和整个**主内存**main memory才能被访问。引导程序的第三个作用是从 BIOS 查询特定的信息,并将其传递到内核;如查询和传递**内存映射表**memory map
编写一个引导程序并不是一个简单的任务,因为这需要使用汇编语言,而且必须经过许多意图并不明显的步骤——比如,把一些**魔术数字**magic number写入某个寄存器。因此我们不会讲解如何编写自己的引导程序而是推荐 [bootimage 工具](https://github.com/rust-osdev/bootimage)——它能够自动并且方便地为你的内核准备一个引导程序。
### Multiboot 标准
每个操作系统都实现自己的引导程序而这只对单个操作系统有效。为了避免这样的僵局1995 年,**自由软件基金会**[Free Software Foundation](https://en.wikipedia.org/wiki/Free_Software_Foundation))颁布了一个开源的引导程序标准——[Multiboot](https://wiki.osdev.org/Multiboot)。这个标准定义了引导程序和操作系统间的统一接口,所以任何适配 Multiboot 的引导程序,都能用来加载任何同样适配了 Multiboot 的操作系统。[GNU GRUB](https://en.wikipedia.org/wiki/GNU_GRUB) 是一个可供参考的 Multiboot 实现它也是最热门的Linux系统引导程序之一。
要编写一款适配 Multiboot 的内核,我们只需要在内核文件开头,插入被称作 **Multiboot头**[Multiboot header](https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format))的数据片段。这让 GRUB 很容易引导任何操作系统但是GRUB 和 Multiboot 标准也有一些可预知的问题:
1. 它们只支持 32 位的保护模式。这意味着,在引导之后,你依然需要配置你的 CPU让它切换到 64 位的长模式;
2. 它们被设计为精简引导程序,而不是精简内核。举个例子,内核需要以调整过的**默认页长度**[default page size](https://wiki.osdev.org/Multiboot#Multiboot_2))被链接,否则 GRUB 将无法找到内核的 Multiboot 头。另一个例子是**引导信息**[boot information](https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format)),这个包含着大量与架构有关的数据,会在引导启动时,被直接传到操作系统,而不会经过一层清晰的抽象;
3. GRUB 和 Multiboot 标准并没有被详细地解释,阅读相关文档需要一定经验;
4. 为了创建一个能够被引导的磁盘映像,我们在开发时必须安装 GRUB这加大了基于 Windows 或 macOS 开发内核的难度。
出于这些考虑,我们决定不使用 GRUB 或者 Multiboot 标准。然而Multiboot 支持功能也在 bootimage 工具的开发计划之中,所以从原理上讲,如果选用 bootimage 工具,在未来使用 GRUB 引导你的系统内核是可能的。 如果你对编写一个支持 Mutiboot 标准的内核有兴趣,可以查阅 [初版文档][first edition]。
[first edition]: @/edition-1/_index.md
### UEFI
截至此时我们并未提供UEFI相关教程但我们确实有此意向。如果你愿意提供一些帮助请在 [Github issue](https://github.com/phil-opp/blog_os/issues/349) 告知我们,不胜感谢。)
## 最小内核
现在我们已经明白电脑是如何启动的那也是时候编写我们自己的内核了。我们的小目标是创建一个内核的磁盘映像它能够在启动时向屏幕输出一行“Hello World!”;我们的工作将基于上一章构建的[独立式可执行程序][freestanding Rust binary]。
如果读者还有印象的话,在上一章,我们使用 `cargo` 构建了一个独立的二进制程序;但这个程序依然基于特定的操作系统平台:因平台而异,我们需要定义不同名称的函数,且使用不同的编译指令。这是因为在默认情况下,`cargo` 会为特定的**宿主系统**host system构建源码比如为你正在运行的系统构建源码。这并不是我们想要的因为我们的内核不应该基于另一个操作系统——我们想要编写的就是这个操作系统。确切地说我们想要的是编译为一个特定的**目标系统**target system
## 安装 Nightly Rust
Rust 语言有三个**发行频道**release channel分别是 stable、beta 和 nightly。《Rust 程序设计语言》中对这三个频道的区别解释得很详细,可以前往[这里](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html)看一看。为了搭建一个操作系统,我们需要一些只有 nightly 会提供的实验性功能,所以我们需要安装一个 nightly 版本的 Rust。
要管理安装好的 Rust我强烈建议使用 [rustup](https://www.rustup.rs/):它允许你同时安装 nightly、beta 和 stable 版本的编译器,而且让更新 Rust 变得容易。你可以输入 `rustup override add nightly` 来选择在当前目录使用 nightly 版本的 Rust。或者你也可以在项目根目录添加一个名称为 `rust-toolchain`、内容为 `nightly` 的文件。要检查你是否已经安装了一个 nightly你可以运行 `rustc --version`:返回的版本号末尾应该包含`-nightly`
Nightly 版本的编译器允许我们在源码的开头插入**特性标签**feature flag来自由选择并使用大量实验性的功能。举个例子要使用实验性的[内联汇编asm!宏)][asm feature],我们可以在 `main.rs` 的顶部添加 `#![feature(asm)]`。要注意的是,这样的实验性功能**不稳定**unstable意味着未来的 Rust 版本可能会修改或移除这些功能,而不会有预先的警告过渡。因此我们只有在绝对必要的时候,才应该使用这些特性。
[asm feature]: https://doc.rust-lang.org/stable/reference/inline-assembly.html
### 目标配置清单
通过 `--target` 参数,`cargo` 支持不同的目标系统。这个目标系统可以使用一个**目标三元组**[target triple](https://clang.llvm.org/docs/CrossCompilation.html#target-triple))来描述,它描述了 CPU 架构、平台供应者、操作系统和**应用程序二进制接口**[Application Binary Interface, ABI](https://stackoverflow.com/a/2456882))。比方说,目标三元组` x86_64-unknown-linux-gnu` 描述一个基于 `x86_64` 架构 CPU 的、没有明确的平台供应者的 linux 系统,它遵循 GNU 风格的 ABI。Rust 支持[许多不同的目标三元组](https://forge.rust-lang.org/release/platform-support.html),包括安卓系统对应的 `arm-linux-androideabi` 和 [WebAssembly使用的wasm32-unknown-unknown](https://www.hellorust.com/setup/wasm-target/)。
为了编写我们的目标系统,并且鉴于我们需要做一些特殊的配置(比如没有依赖的底层操作系统),[已经支持的目标三元组](https://forge.rust-lang.org/release/platform-support.html)都不能满足我们的要求。幸运的是,只需使用一个 JSON 文件Rust 便允许我们定义自己的目标系统;这个文件常被称作**目标配置清单**target specification。比如一个描述 `x86_64-unknown-linux-gnu` 目标系统的配置清单大概长这样:
```json
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
```
一个配置清单中包含多个**配置项**field。大多数的配置项都是 LLVM 需求的,它们将配置为特定平台生成的代码。打个比方,`data-layout` 配置项定义了不同的整数、浮点数、指针类型的长度;另外,还有一些 Rust 用作条件编译的配置项,如 `target-pointer-width`。还有一些类型的配置项,定义了这个包该如何被编译,例如,`pre-link-args` 配置项指定了应该向**链接器**[linker](https://en.wikipedia.org/wiki/Linker_(computing)))传入的参数。
我们将把我们的内核编译到 `x86_64` 架构,所以我们的配置清单将和上面的例子相似。现在,我们来创建一个名为 `x86_64-blog_os.json` 的文件——当然也可以选用自己喜欢的文件名——里面包含这样的内容:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true
}
```
需要注意的是,因为我们要在**裸机**bare metal上运行内核我们已经修改了 `llvm-target` 的内容,并将 `os` 配置项的值改为 `none`
我们还需要添加下面与编译相关的配置项:
```json
"linker-flavor": "ld.lld",
"linker": "rust-lld",
```
在这里,我们不使用平台默认提供的链接器,因为它可能不支持 Linux 目标系统。为了链接我们的内核,我们使用跨平台的 **LLD链接器**[LLD linker](https://lld.llvm.org/)),它是和 Rust 一起打包发布的。
```json
"panic-strategy": "abort",
```
这个配置项的意思是,我们的编译目标不支持 panic 时的**栈展开**[stack unwinding](https://www.bogotobogo.com/cplusplus/stackunwinding.php)),所以我们选择直接**在 panic 时中止**abort on panic。这和在 `Cargo.toml` 文件中添加 `panic = "abort"` 选项的作用是相同的,所以我们可以不在这里的配置清单中填写这一项。
```json
"disable-redzone": true,
```
我们正在编写一个内核,所以我们迟早要处理中断。要安全地实现这一点,我们必须禁用一个与**红区**redzone有关的栈指针优化因为此时这个优化可能会导致栈被破坏。如果需要更详细的资料请查阅我们的一篇关于 [禁用红区][disabling the red zone] 的短文。
[disabling the red zone]: @/edition-2/posts/02-minimal-rust-kernel/disable-red-zone/index.zh-CN.md
```json
"features": "-mmx,-sse,+soft-float",
```
`features` 配置项被用来启用或禁用某个目标 **CPU 特征**CPU feature。通过在它们前面添加`-`号,我们将 `mmx``sse` 特征禁用;添加前缀`+`号,我们启用了 `soft-float` 特征。
`mmx``sse` 特征决定了是否支持**单指令多数据流**[Single Instruction Multiple DataSIMD](https://en.wikipedia.org/wiki/SIMD))相关指令,这些指令常常能显著地提高程序层面的性能。然而,在内核中使用庞大的 SIMD 寄存器,可能会造成较大的性能影响:因为每次程序中断时,内核不得不储存整个庞大的 SIMD 寄存器以备恢复——这意味着,对每个硬件中断或系统调用,完整的 SIMD 状态必须存到主存中。由于 SIMD 状态可能相当大512~1600 个字节),而中断可能时常发生,这些额外的存储与恢复操作可能显著地影响效率。为解决这个问题,我们对内核禁用 SIMD但这不意味着禁用内核之上的应用程序的 SIMD 支持)。
禁用 SIMD 产生的一个问题是,`x86_64` 架构的浮点数指针运算默认依赖于 SIMD 寄存器。我们的解决方法是,启用 `soft-float` 特征,它将使用基于整数的软件功能,模拟浮点数指针运算。
为了让读者的印象更清晰,我们撰写了一篇关于 [禁用 SIMD][disabling SIMD](@/edition-2/posts/02-minimal-rust-kernel/disable-simd/index.zh-CN.md) 的短文。
现在,我们将各个配置项整合在一起。我们的目标配置清单应该长这样:
```json
{
"llvm-target": "x86_64-unknown-none",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "none",
"executables": true,
"linker-flavor": "ld.lld",
"linker": "rust-lld",
"panic-strategy": "abort",
"disable-redzone": true,
"features": "-mmx,-sse,+soft-float"
}
```
### 编译内核
要编译我们的内核,我们将使用 Linux 系统的编写风格(这可能是 LLVM 的默认风格)。这意味着,我们需要把[前一篇文章][previous post]中编写的入口点重命名为 `_start`
[previous post]: @/edition-2/posts/01-freestanding-rust-binary/index.md
```rust
// src/main.rs
#![no_std] // 不链接 Rust 标准库
#![no_main] // 禁用所有 Rust 层级的入口点
use core::panic::PanicInfo;
/// 这个函数将在 panic 时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle] // 不重整函数名
pub extern "C" fn _start() -> ! {
// 因为编译器会寻找一个名为 `_start` 的函数,所以这个函数就是入口点
// 默认命名为 `_start`
loop {}
}
```
注意的是,无论你开发使用的是哪类操作系统,你都需要将入口点命名为 `_start`。前一篇文章中编写的 Windows 系统和 macOS 对应的入口点不应该被保留。
通过把 JSON 文件名传入 `--target` 选项,我们现在可以开始编译我们的内核。让我们试试看:
```
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
```
毫不意外的编译失败了,错误信息告诉我们编译器没有找到 [`core`][`core` library] 这个crate它包含了Rust语言中的部分基础类型`Result``Option`、迭代器等等,并且它还会隐式链接到 `no_std` 特性里面。
[`core` library]: https://doc.rust-lang.org/nightly/core/index.html
通常状况下,`core` crate以**预编译库**precompiled library的形式与 Rust 编译器一同发布——这时,`core` crate只对支持的宿主系统有效而对我们自定义的目标系统无效。如果我们想为其它系统编译代码我们需要为这些系统重新编译整个 `core` crate。
#### `build-std` 选项
此时就到了cargo中 [`build-std` 特性][`build-std` feature] 登场的时刻,该特性允许你按照自己的需要重编译 `core` 等标准crate而不需要使用Rust安装程序内置的预编译版本。 但是该特性是全新的功能,到目前为止尚未完全完成,所以它被标记为 "unstable" 且仅被允许在 [nightly Rust 编译器][nightly Rust compilers] 环境下调用。
[`build-std` feature]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std
[nightly Rust compilers]: #安装 Nightly Rust
要启用该特性,你需要创建一个 [cargo 配置][cargo configuration] 文件,即 `.cargo/config.toml`,并写入以下语句:
```toml
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
```
该配置会告知cargo需要重新编译 `core``compiler_builtins` 这两个crate其中 `compiler_builtins``core` 的必要依赖。 另外重编译需要提供源码,我们可以使用 `rustup component add rust-src` 命令来下载它们。
<div class="note">
**Note:**`2020-07-15` 之后的Rust nightly版本支持 `unstable.build-std` 配置项。
</div>
在设定 `unstable.build-std` 配置项并安装 `rust-src` 组件之后,我们就可以开始编译了:
```
> cargo build --target x86_64-blog_os.json
Compiling core v0.0.0 (/…/rust/src/libcore)
Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
Compiling compiler_builtins v0.1.32
Compiling blog_os v0.1.0 (/…/blog_os)
Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs
```
如你所见,在执行 `cargo build` 之后, `core``rustc-std-workspace-core` `compiler_builtins` 的依赖)和 `compiler_builtins` crate被重新编译了。
#### 内存相关函数
目前来说Rust编译器假定所有内置函数`built-in functions`)在所有系统内都是存在且可用的。事实上这个前提只对了一半,
绝大多数内置函数都可以被 `compiler_builtins` 提供而这个crate刚刚已经被我们重编译过了然而部分内存相关函数是需要操作系统相关的标准C库提供的。
比如,`memset`(该函数可以为一个内存块内的所有比特进行赋值)、`memcpy`(将一个内存块里的数据拷贝到另一个内存块)以及`memcmp`(比较两个内存块的数据)。
好在我们的内核暂时还不需要用到这些函数,但是不要高兴的太早,当我们编写更丰富的功能(比如拷贝数据结构)时就会用到了。
现在我们当然无法提供操作系统相关的标准C库所以我们需要使用其他办法提供这些东西。一个显而易见的途径就是自己实现 `memset` 这些函数,但不要忘记加入 `#[no_mangle]` 语句,以避免编译时被自动重命名。 当然,这样做很危险,底层函数中最细微的错误也会将程序导向不可预知的未来。比如,你可能在实现 `memcpy` 时使用了一个 `for` 循环,然而 `for` 循环本身又会调用 [`IntoIterator::into_iter`] 这个trait方法这个方法又会再次调用 `memcpy`,此时一个无限递归就产生了,所以还是使用经过良好测试的既存实现更加可靠。
[`IntoIterator::into_iter`]: https://doc.rust-lang.org/stable/core/iter/trait.IntoIterator.html#tymethod.into_iter
幸运的是,`compiler_builtins` 事实上自带了所有相关函数的实现只是在默认情况下出于避免和标准C库发生冲突的考量被禁用掉了此时我们需要将 [`build-std-features`] 配置项设置为 `["compiler-builtins-mem"]` 来启用这个特性。如同 `build-std` 配置项一样,该特性可以使用 `-Z` 参数启用,也可以在 `.cargo/config.toml` 中使用 `unstable` 配置集启用。现在我们的配置文件中的相关部分是这样子的:
[`build-std-features`]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features
```toml
# in .cargo/config.toml
[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "compiler_builtins"]
```
`compiler-builtins-mem` 特性是在 [这个PR](https://github.com/rust-lang/rust/pull/77284) 中被引入的所以你的Rust nightly更新时间必须晚于 `2020-09-30`。)
该参数为 `compiler_builtins` 启用了 [`mem` 特性][`mem` feature],至于具体效果,就是已经在内部通过 `#[no_mangle]` 向链接器提供了 [`memcpy` 等函数的实现][`memcpy` etc. implementations]。
[`mem` feature]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/Cargo.toml#L54-L55
[`memcpy` etc. implementations]: https://github.com/rust-lang/compiler-builtins/blob/eff506cd49b637f1ab5931625a33cef7e91fbbf6/src/mem.rs#L12-L69
经过这些修改,我们的内核已经完成了所有编译所必需的函数,那么让我们继续对代码进行完善。
#### 设置默认编译目标
每次调用 `cargo build` 命令都需要传入 `--target` 参数很麻烦吧?其实我们可以复写掉默认值,从而省略这个参数,只需要在 `.cargo/config.toml` 中加入以下 [cargo 配置][cargo configuration]
[cargo configuration]: https://doc.rust-lang.org/cargo/reference/config.html
```toml
# in .cargo/config.toml
[build]
target = "x86_64-blog_os.json"
```
这个配置会告知 `cargo` 使用 `x86_64-blog_os.json` 这个文件作为默认的 `--target` 参数,此时只输入短短的一句 `cargo build` 就可以编译到指定平台了。如果你对其他配置项感兴趣,亦可以查阅 [官方文档][cargo configuration]。
那么现在我们已经可以用 `cargo build` 完成程序编译了,然而被成功调用的 `_start` 函数的函数体依然是一个空空如也的循环,是时候往屏幕上输出一点什么了。
### 向屏幕打印字符
要做到这一步,最简单的方式是写入 **VGA 字符缓冲区**[VGA text buffer](https://en.wikipedia.org/wiki/VGA-compatible_text_mode)):这是一段映射到 VGA 硬件的特殊内存片段,包含着显示在屏幕上的内容。通常情况下,它能够存储 25 行、80 列共 2000 个**字符单元**character cell每个字符单元能够显示一个 ASCII 字符,也能设置这个字符的**前景色**foreground color和**背景色**background color。输出到屏幕的字符大概长这样
![](https://upload.wikimedia.org/wikipedia/commons/6/6d/Codepage-737.png)
我们将在下篇文章中详细讨论 VGA 字符缓冲区的内存布局;目前我们只需要知道,这段缓冲区的地址是 `0xb8000`,且每个字符单元包含一个 ASCII 码字节和一个颜色字节。
我们的实现就像这样:
```rust
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
```
在这段代码中,我们预先定义了一个**字节字符串**byte string类型的**静态变量**static variable名为 `HELLO`。我们首先将整数 `0xb8000` **转换**cast为一个**裸指针**[raw pointer])。这之后,我们迭代 `HELLO` 的每个字节,使用 [enumerate](https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate) 获得一个额外的序号变量 `i`。在 `for` 语句的循环体中,我们使用 [offset](https://doc.rust-lang.org/std/primitive.pointer.html#method.offset) 偏移裸指针,解引用它,来将字符串的每个字节和对应的颜色字节——`0xb` 代表淡青色——写入内存位置。
[raw pointer]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
要注意的是,所有的裸指针内存操作都被一个 **unsafe 语句块**[unsafe block](https://doc.rust-lang.org/stable/book/second-edition/ch19-01-unsafe-rust.html))包围。这是因为,此时编译器不能确保我们创建的裸指针是有效的;一个裸指针可能指向任何一个你内存位置;直接解引用并写入它,也许会损坏正常的数据。使用 `unsafe` 语句块时,程序员其实在告诉编译器,自己保证语句块内的操作是有效的。事实上,`unsafe` 语句块并不会关闭 Rust 的安全检查机制;它允许你多做的事情[只有四件][unsafe superpowers]。
[unsafe superpowers]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers
使用 `unsafe` 语句块要求程序员有足够的自信,所以必须强调的一点是,**肆意使用 unsafe 语句块并不是 Rust 编程的一贯方式**。在缺乏足够经验的前提下,直接在 `unsafe` 语句块内操作裸指针,非常容易把事情弄得很糟糕;比如,在不注意的情况下,我们很可能会意外地操作缓冲区以外的内存。
在这样的前提下,我们希望最小化 `unsafe ` 语句块的使用。使用 Rust 语言,我们能够将不安全操作将包装为一个安全的抽象模块。举个例子,我们可以创建一个 VGA 缓冲区类型,把所有的不安全语句封装起来,来确保从类型外部操作时,无法写出不安全的代码:通过这种方式,我们只需要最少的 `unsafe` 语句块来确保我们不破坏**内存安全**[memory safety](https://en.wikipedia.org/wiki/Memory_safety))。在下一篇文章中,我们将会创建这样的 VGA 缓冲区封装。
## 启动内核
既然我们已经有了一个能够打印字符的可执行程序,是时候把它运行起来试试看了。首先,我们将编译完毕的内核与引导程序链接,来创建一个引导映像;这之后,我们可以在 QEMU 虚拟机中运行它,或者通过 U 盘在真机上运行。
### 创建引导映像
要将可执行程序转换为**可引导的映像**bootable disk image我们需要把它和引导程序链接。这里引导程序将负责初始化 CPU 并加载我们的内核。
编写引导程序并不容易,所以我们不编写自己的引导程序,而是使用已有的 [bootloader](https://crates.io/crates/bootloader) 包;无需依赖于 C 语言,这个包基于 Rust 代码和内联汇编,实现了一个五脏俱全的 BIOS 引导程序。为了用它启动我们的内核,我们需要将它添加为一个依赖项,在 `Cargo.toml` 中添加下面的代码:
```toml
# in Cargo.toml
[dependencies]
bootloader = "0.9.23"
```
只添加引导程序为依赖项,并不足以创建一个可引导的磁盘映像;我们还需要内核编译完成之后,将内核和引导程序组合在一起。然而,截至目前,原生的 cargo 并不支持在编译完成后添加其它步骤(详见[这个 issue](https://github.com/rust-lang/cargo/issues/545))。
为了解决这个问题,我们建议使用 `bootimage` 工具——它将会在内核编译完毕后,将它和引导程序组合在一起,最终创建一个能够引导的磁盘映像。我们可以使用下面的命令来安装这款工具:
```bash
cargo install bootimage
```
为了运行 `bootimage` 以及编译引导程序,我们需要安装 rustup 模块 `llvm-tools-preview`——我们可以使用 `rustup component add llvm-tools-preview` 来安装这个工具。
成功安装 `bootimage` 后,创建一个可引导的磁盘映像就变得相当容易。我们来输入下面的命令:
```bash
> cargo bootimage
```
可以看到的是,`bootimage` 工具开始使用 `cargo build` 编译你的内核,所以它将增量编译我们修改后的源码。在这之后,它会编译内核的引导程序,这可能将花费一定的时间;但和所有其它依赖包相似的是,在首次编译后,产生的二进制文件将被缓存下来——这将显著地加速后续的编译过程。最终,`bootimage` 将把内核和引导程序组合为一个可引导的磁盘映像。
运行这行命令之后,我们应该能在 `target/x86_64-blog_os/debug` 目录内找到我们的映像文件 `bootimage-blog_os.bin`。我们可以在虚拟机内启动它,也可以刻录到 U 盘上以便在真机上启动。(需要注意的是,因为文件格式不同,这里的 bin 文件并不是一个光驱映像,所以将它刻录到光盘不会起作用。)
事实上,在这行命令背后,`bootimage` 工具执行了三个步骤:
1. 编译我们的内核为一个 **ELF**[Executable and Linkable Format](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format))文件;
2. 编译引导程序为独立的可执行文件;
3. 将内核 ELF 文件**按字节拼接**append by bytes到引导程序的末端。
当机器启动时,引导程序将会读取并解析拼接在其后的 ELF 文件。这之后,它将把程序片段映射到**分页表**page table中的**虚拟地址**virtual address清零 **BSS段**BSS segment还将创建一个栈。最终它将读取**入口点地址**entry point address——我们程序中 `_start` 函数的位置——并跳转到这个位置。
### 在 QEMU 中启动内核
现在我们可以在虚拟机中启动内核了。为了在[ QEMU](https://www.qemu.org/) 中启动内核,我们使用下面的命令:
```bash
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]
```
然后就会弹出一个独立窗口:
![QEMU showing "Hello World!"](qemu.png)
我们可以看到,屏幕窗口已经显示出 “Hello World!” 字符串。祝贺你!
### 在真机上运行内核
我们也可以使用 dd 工具把内核写入 U 盘,以便在真机上启动。可以输入下面的命令:
```bash
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync
```
在这里,`sdX` 是U盘的**设备名**[device name](https://en.wikipedia.org/wiki/Device_file))。请注意,**在选择设备名的时候一定要极其小心,因为目标设备上已有的数据将全部被擦除**。
写入到 U 盘之后,你可以在真机上通过引导启动你的系统。视情况而定,你可能需要在 BIOS 中打开特殊的启动菜单,或者调整启动顺序。需要注意的是,`bootloader` 包暂时不支持 UEFI所以我们并不能在 UEFI 机器上启动。
### 使用 `cargo run`
要让在 QEMU 中运行内核更轻松,我们可以设置在 cargo 配置文件中设置 `runner` 配置项:
```toml
# in .cargo/config.toml
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
```
在这里,`target.'cfg(target_os = "none")'` 筛选了三元组中宿主系统设置为 `"none"` 的所有编译目标——这将包含我们的 `x86_64-blog_os.json` 目标。另外,`runner` 的值规定了运行 `cargo run` 使用的命令;这个命令将在成功编译后执行,而且会传递可执行文件的路径为第一个参数。[官方提供的 cargo 文档](https://doc.rust-lang.org/cargo/reference/config.html)讲述了更多的细节。
命令 `bootimage runner``bootimage` 包提供,参数格式经过特殊设计,可以用于 `runner` 命令。它将给定的可执行文件与项目的引导程序依赖项链接,然后在 QEMU 中启动它。`bootimage` 包的 [README文档](https://github.com/rust-osdev/bootimage) 提供了更多细节和可以传入的配置参数。
现在我们可以使用 `cargo run` 来编译内核并在 QEMU 中启动了。
## 下篇预告
在下篇文章中,我们将细致地探索 VGA 字符缓冲区,并包装它为一个安全的接口。我们还将基于它实现 `println!` 宏。

View File

@@ -1,703 +0,0 @@
+++
title = "حالت متن VGA"
weight = 3
path = "fa/vga-text-mode"
date = 2018-02-26
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "fb8b03e82d9805473fed16e8795a78a020a6b537"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
[حالت متن VGA] یک روش ساده برای چاپ متن روی صفحه است. در این پست ، با قرار دادن همه موارد غیر ایمنی در یک ماژول جداگانه ، رابطی ایجاد می کنیم که استفاده از آن را ایمن و ساده می کند. همچنین پشتیبانی از [ماکروی فرمت‌بندی] راست را پیاده سازی میکنیم.
[حالت متن VGA]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
[ماکروی فرمت‌بندی]: https://doc.rust-lang.org/std/fmt/#related-macros
<!-- more -->
این بلاگ بصورت آزاد بر روی [گیت‌هاب] توسعه داده شده. اگر مشکل یا سوالی دارید، لطفا آنجا یک ایشو باز کنید. همچنین می‌توانید [در زیر] این پست کامنت بگذارید. سورس کد کامل این پست را می توانید در شاخه [`post-01`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-03
<!-- toc -->
## بافر متن VGA
برای چاپ یک کاراکتر روی صفحه در حالت متن VGA ، باید آن را در بافر متن سخت افزار VGA بنویسید. بافر متن VGA یک آرایه دو بعدی است که به طور معمول 25 ردیف و 80 ستون دارد که مستقیماً به صفحه نمایش داده(رندر) می شود. هر خانه آرایه یک کاراکتر صفحه نمایش را از طریق قالب زیر توصیف می کند:
Bit(s) | Value
------ | ----------------
0-7 | ASCII code point
8-11 | Foreground color
12-14 | Background color
15 | Blink
اولین بایت کاراکتری در [کدگذاری ASCII] را نشان می دهد که باید چاپ شود. اگر بخواهیم دقیق باشیم ، دقیقاً ASCII نیست ، بلکه مجموعه ای از کاراکترها به نام [_کد صفحه 437_] با برخی کاراکتر های اضافی و تغییرات جزئی است. برای سادگی ، ما در این پست آنرا یک کاراکتر ASCII می نامیم.
[کدگذاری ASCII]: https://en.wikipedia.org/wiki/ASCII
[_کد صفحه 437_]: https://en.wikipedia.org/wiki/Code_page_437
بایت دوم نحوه نمایش کاراکتر را مشخص می کند. چهار بیت اول رنگ پیش زمینه را مشخص می کند ، سه بیت بعدی رنگ پس زمینه و بیت آخر اینکه کاراکتر باید چشمک بزند یا نه. رنگ های زیر موجود است:
Number | Color | Number + Bright Bit | Bright Color
------ | ---------- | ------------------- | -------------
0x0 | Black | 0x8 | Dark Gray
0x1 | Blue | 0x9 | Light Blue
0x2 | Green | 0xa | Light Green
0x3 | Cyan | 0xb | Light Cyan
0x4 | Red | 0xc | Light Red
0x5 | Magenta | 0xd | Pink
0x6 | Brown | 0xe | Yellow
0x7 | Light Gray | 0xf | White
بیت 4، بیت روشنایی است ، که به عنوان مثال آبی به آبی روشن تبدیل می‌کند. برای رنگ پس زمینه ، این بیت به عنوان بیت چشمک مورد استفاده قرار می گیرد.
بافر متن VGA از طریق [ورودی/خروجی حافظه‌نگاشتی] به آدرس`0xb8000` قابل دسترسی است. این بدان معنی است که خواندن و نوشتن در آن آدرس به RAM دسترسی ندارد ، بلکه مستقیماً دسترسی به بافر متن در سخت افزار VGA دارد. این بدان معنی است که می توانیم آن را از طریق عملیات حافظه عادی در آن آدرس بخوانیم و بنویسیم.
[ورودی/خروجی حافظه‌نگاشتی]: https://en.wikipedia.org/wiki/Memory-mapped_I/O
توجه داشته باشید که ممکن است سخت افزار حافظه‌نگاشتی شده از تمام عملیات معمول RAM پشتیبانی نکند. به عنوان مثال ، یک دستگاه ممکن است فقط خواندن بایتی را پشتیبانی کرده و با خواندن `u64` یک مقدار زباله را برگرداند. خوشبختانه بافر متن [از خواندن و نوشتن عادی پشتیبانی می کند] ، بنابراین مجبور نیستیم با آن به روش خاصی برخورد کنیم.
[از خواندن و نوشتن عادی پشتیبانی می کند]: https://web.stanford.edu/class/cs140/projects/pintos/specs/freevga/vga/vgamem.htm#manip
## یک ماژول راست
اکنون که از نحوه کار بافر VGA مطلع شدیم ، می توانیم یک ماژول Rust برای مدیریت چاپ ایجاد کنیم:
```rust
//in src/main.rs
mod vga_buffer;
```
برای محتوای این ماژول ما یک فایل جدید `src/vga_buffer.rs` ایجاد می کنیم. همه کدهای زیر وارد ماژول جدید ما می شوند (مگر اینکه طور دیگری مشخص شده باشد).
### رنگ ها
اول ، ما رنگ های مختلف را با استفاده از یک enum نشان می دهیم:
```rust
// in src/vga_buffer.rs
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
```
ما در اینجا از [enum مانند C] برای مشخص کردن صریح عدد برای هر رنگ استفاده می کنیم. به دلیل ویژگی `repr(u8)` هر نوع enum به عنوان یک `u8` ذخیره می شود. در واقع 4 بیت کافی است ، اما Rust نوع `u4` ندارد.
[enum مانند C]: https://doc.rust-lang.org/rust-by-example/custom_types/enum/c_like.html
به طور معمول کامپایلر برای هر نوع استفاده نشده اخطار می دهد. با استفاده از ویژگی `#[allow(dead_code)]` این هشدارها را برای enum `Color` غیرفعال می کنیم.
توسط [deriving] کردن تریت‌های [`Copy`], [`Clone`], [`Debug`], [`PartialEq`], و [`Eq`] ما [مفهوم کپی] را برای نوع فعال کرده و آن را قابل پرینت کردن میکنیم.
[deriving]: https://doc.rust-lang.org/rust-by-example/trait/derive.html
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[`Clone`]: https://doc.rust-lang.org/nightly/core/clone/trait.Clone.html
[`Debug`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Debug.html
[`PartialEq`]: https://doc.rust-lang.org/nightly/core/cmp/trait.PartialEq.html
[`Eq`]: https://doc.rust-lang.org/nightly/core/cmp/trait.Eq.html
[مفهوم کپی]: https://doc.rust-lang.org/1.30.0/book/first-edition/ownership.html#copy-types
برای نشان دادن یک کد کامل رنگ که رنگ پیش زمینه و پس زمینه را مشخص می کند ، یک [نوع جدید] بر روی `u8` ایجاد می کنیم:
[نوع جدید]: https://doc.rust-lang.org/rust-by-example/generics/new_types.html
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
```
ساختمان `ColorCode` شامل بایت کامل رنگ است که شامل رنگ پیش زمینه و پس زمینه است. مانند قبل ، ویژگی های `Copy` و` Debug` را برای آن derive می کنیم. برای اطمینان از اینکه `ColorCode` دقیقاً ساختار داده مشابه `u8` دارد ، از ویژگی [`repr(transparent)`] استفاده می کنیم.
[`repr(transparent)`]: https://doc.rust-lang.org/nomicon/other-reprs.html#reprtransparent
### بافر متن
اکنون می توانیم ساختمان‌هایی را برای نمایش یک کاراکتر صفحه و بافر متن اضافه کنیم:
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
#[repr(transparent)]
struct Buffer {
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
از آنجا که ترتیب فیلدهای ساختمان‌های پیش فرض در Rust تعریف نشده است ، به ویژگی[`repr(C)`] نیاز داریم. این تضمین می کند که فیلد های ساختمان دقیقاً مانند یک ساختمان C ترسیم شده اند و بنابراین ترتیب درست را تضمین می کند. برای ساختمان `Buffer` ، ما دوباره از [`repr(transparent)`] استفاده می کنیم تا اطمینان حاصل شود که نحوه قرارگیری در حافظه دقیقا همان یک فیلد است.
[`repr(C)`]: https://doc.rust-lang.org/nightly/nomicon/other-reprs.html#reprc
برای نوشتن در صفحه ، اکنون یک نوع نویسنده ایجاد می کنیم:
```rust
// in src/vga_buffer.rs
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
```
نویسنده همیشه در آخرین خط مینویسد و وقتی خط پر است (یا در `\n`) ، سطرها را به سمت بالا شیفت می دهد. فیلد `column_position` موقعیت فعلی در ردیف آخر را نگهداری می کند. رنگهای پیش زمینه و پس زمینه فعلی توسط `color_code` مشخص شده و یک ارجاع (رفرنس) به بافر VGA در `buffer` ذخیره می شود. توجه داشته باشید که ما در اینجا به [طول عمر مشخصی] نیاز داریم تا به کامپایلر بگوییم تا چه مدت این ارجاع معتبر است. ظول عمر [`'static`] مشخص می کند که ارجاع برای کل مدت زمان اجرای برنامه معتبر باشد (که برای بافر متن VGA درست است).
[طول عمر مشخصی]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-annotation-syntax
[`'static`]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
### چاپ کردن
اکنون می توانیم از `Writer` برای تغییر کاراکترهای بافر استفاده کنیم. ابتدا یک متد برای نوشتن یک بایت ASCII ایجاد می کنیم:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
self.buffer.chars[row][col] = ScreenChar {
ascii_character: byte,
color_code,
};
self.column_position += 1;
}
}
}
fn new_line(&mut self) {/* TODO */}
}
```
اگر بایت، بایتِ [خط جدید] `\n` باشد، نویسنده چیزی چاپ نمی کند. در عوض متد `new_line` را فراخوانی می کند که بعداً آن را پیاده‌سازی خواهیم کرد. بایت های دیگر در حالت دوم match روی صفحه چاپ می شوند.
[خط جدید]: https://en.wikipedia.org/wiki/Newline
هنگام چاپ بایت ، نویسنده بررسی می کند که آیا خط فعلی پر است یا نه. در صورت پُر بودن، برای نوشتن در خط ، باید متد `new_line` صدا زده شود. سپس یک `ScreenChar` جدید در بافر در موقعیت فعلی می نویسد. سرانجام ، موقعیت ستون فعلی یکی افزایش می‌یابد.
برای چاپ کل رشته ها، می توانیم آنها را به بایت تبدیل کرده و یکی یکی چاپ کنیم:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
// printable ASCII byte or newline
0x20..=0x7e | b'\n' => self.write_byte(byte),
// not part of printable ASCII range
_ => self.write_byte(0xfe),
}
}
}
}
```
بافر متن VGA فقط از ASCII و بایت های اضافی [کد صفحه 437] پشتیبانی می کند. رشته های راست به طور پیش فرض [UTF-8] هستند ، بنابراین ممکن است حاوی بایت هایی باشند که توسط بافر متن VGA پشتیبانی نمی شوند. ما از یک match برای تفکیک بایت های قابل چاپ ASCII (یک خط جدید یا هر چیز دیگری بین یک کاراکتر فاصله و یک کاراکتر`~`) و بایت های غیر قابل چاپ استفاده می کنیم. برای بایت های غیر قابل چاپ ، یک کاراکتر `■` چاپ می کنیم که دارای کد شانزده‌ای (hex) `0xfe` بر روی سخت افزار VGA است.
[کد صفحه 437]: https://en.wikipedia.org/wiki/Code_page_437
[UTF-8]: https://www.fileformat.info/info/unicode/utf8.htm
#### امتحان‌اش کنید!
برای نوشتن چند کاراکتر بر روی صفحه ، می توانید یک تابع موقتی ایجاد کنید:
```rust
// in src/vga_buffer.rs
pub fn print_something() {
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello ");
writer.write_string("Wörld!");
}
```
ابتدا یک Writer جدید ایجاد می کند که به بافر VGA در `0xb8000` اشاره دارد. سینتکس این ممکن است کمی عجیب به نظر برسد: اول ، ما عدد صحیح `0xb8000` را به عنوان [اشاره گر خام] قابل تغییر در نظر می گیریم. سپس با dereferencing کردن آن (از طریق "*") و بلافاصله ارجاع مجدد (از طریق `&mut`) آن را به یک مرجع قابل تغییر تبدیل می کنیم. این تبدیل به یک [بلوک `غیرایمن`] احتیاج دارد ، زیرا کامپایلر نمی تواند صحت اشاره‌گر خام را تضمین کند.
[اشاره گر خام]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[بلوک `غیرایمن`]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
سپس بایت `b'H'` را روی آن می نویسد. پیشوند `b` یک [بایت لیترال] ایجاد می کند ، که بیانگر یک کاراکتر ASCII است. با نوشتن رشته های `"ello "` و `"Wörld!"` ، ما متد `write_string` و واکنش به کاراکترهای غیر قابل چاپ را آزمایش می کنیم. برای دیدن خروجی ، باید تابع `print_something` را از تابع `_start` فراخوانی کنیم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
vga_buffer::print_something();
loop {}
}
```
اکنون هنگامی که ما پروژه را اجرا می کنیم ، باید یک `Hello W■■rld!` در گوشه سمت چپ _پایین_ صفحه به رنگ زرد چاپ شود:
[بایت لیترال]: https://doc.rust-lang.org/reference/tokens.html#byte-literals
![QEMU output with a yellow `Hello W■■rld!` in the lower left corner](vga-hello.png)
توجه داشته باشید که `ö` به عنوان دو کاراکتر `■` چاپ شده است. به این دلیل که `ö` با دو بایت در [UTF-8] نمایش داده می شود ، که هر دو در محدوده قابل چاپ ASCII قرار نمی گیرند. در حقیقت ، این یک ویژگی اساسی UTF-8 است: هر بایت از مقادیر چند بایتی هرگز ASCII معتبر نیستند.
### فرّار
ما الان دیدیم که پیام ما به درستی چاپ شده است. با این حال ، ممکن است با کامپایلرهای آینده Rust که به صورت تهاجمی تری(aggressively) بهینه می شوند ، کار نکند.
مشکل این است که ما فقط به `Buffer` می نویسیم و هرگز از آن نمیخوانیم. کامپایلر نمی داند که ما واقعاً به حافظه بافر VGA (به جای RAM معمولی) دسترسی پیدا می کنیم و در مورد اثر جانبی آن یعنی نمایش برخی کاراکتر ها روی صفحه چیزی نمی داند. بنابراین ممکن است تصمیم بگیرد که این نوشتن ها غیرضروری هستند و می تواند آن را حذف کند. برای جلوگیری از این بهینه سازی اشتباه ، باید این نوشتن ها را به عنوان _[فرّار]_ مشخص کنیم. این به کامپایلر می گوید که نوشتن عوارض جانبی دارد و نباید بهینه شود.
[فرّار]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
به منظور استفاده از نوشتن های فرار برای بافر VGA ، ما از کتابخانه [volatile][volatile crate] استفاده می کنیم. این _crate_ (بسته ها در جهان Rust اینطور نامیده می‌شوند) نوع `Volatile` را که یک نوع wrapper هست با متد های `read` و `write` فراهم می کند. این متد ها به طور داخلی از توابع [read_volatile] و [write_volatile] کتابخانه اصلی استفاده می کنند و بنابراین تضمین می کنند که خواندن/ نوشتن با بهینه شدن حذف نمی‌شوند.
[volatile crate]: https://docs.rs/volatile
[read_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.read_volatile.html
[write_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.write_volatile.html
ما می توانیم وابستگی به کرت (crate) `volatile` را بوسیله اضافه کردن آن به بخش `dependencies` (وابستگی های) `Cargo.toml` اضافه کنیم:
```toml
# in Cargo.toml
[dependencies]
volatile = "0.2.6"
```
`0.2.6` شماره نسخه [معنایی] است. برای اطلاعات بیشتر ، به راهنمای [تعیین وابستگی ها] مستندات کارگو (cargo) مراجعه کنید.
[معنایی]: https://semver.org/
[تعیین وابستگی ها]: https://doc.crates.io/specifying-dependencies.html
بیایید از آن برای نوشتن فرار در بافر VGA استفاده کنیم. نوع `Buffer` خود را به صورت زیر بروزرسانی می کنیم:
```rust
// in src/vga_buffer.rs
use volatile::Volatile;
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
به جای `ScreenChar` ، ما اکنون از `Volatile<ScreenChar>` استفاده می کنیم. (نوع `Volatile`، [generic] است و می تواند (تقریباً) هر نوع را در خود قرار دهد). این اطمینان می دهد که ما به طور تصادفی نمی توانیم از طریق نوشتن "عادی" در آن بنویسیم. در عوض ، اکنون باید از متد `write` استفاده کنیم.
[generic]: https://doc.rust-lang.org/book/ch10-01-syntax.html
این بدان معنی است که ما باید متد `Writer::write_byte` خود را به روز کنیم:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
...
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code,
});
...
}
}
}
...
}
```
به جای انتساب عادی با استفاده از `=` ، اکنون ما از متد `write` استفاده می کنیم. این تضمین می کند که کامپایلر هرگز این نوشتن را بهینه نخواهد کرد.
### ماکرو‌های قالب‌بندی
خوب است که از ماکروهای قالب بندی Rust نیز پشتیبانی کنید. به این ترتیب ، می توانیم انواع مختلفی مانند عدد صحیح یا شناور را به راحتی چاپ کنیم. برای پشتیبانی از آنها ، باید تریت [`core::fmt::Write`] را پیاده سازی کنیم. تنها متد مورد نیاز این تریت ،`write_str` است که کاملاً شبیه به متد `write_str` ما است ، فقط با نوع بازگشت `fmt::Result`:
[`core::fmt::Write`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Write.html
```rust
// in src/vga_buffer.rs
use core::fmt;
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
```
`Ok(())` فقط نتیجه `Ok` حاوی نوع `()` است.
اکنون ما می توانیم از ماکروهای قالب بندی داخلی راست یعنی `write!`/`writeln!` استفاده کنیم:
```rust
// in src/vga_buffer.rs
pub fn print_something() {
use core::fmt::Write;
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello! ");
write!(writer, "The numbers are {} and {}", 42, 1.0/3.0).unwrap();
}
```
حالا شما باید یک `Hello! The numbers are 42 and 0.3333333333333333` در پایین صفحه ببینید. فراخوانی `write!` یک `Result` را برمی گرداند که در صورت عدم استفاده باعث هشدار می شود ، بنابراین ما تابع [`unwrap`] را روی آن فراخوانی می کنیم که در صورت بروز خطا پنیک می کند. این در مورد ما مشکلی ندارد ، زیرا نوشتن در بافر VGA هرگز شکست نمیخورد.
[`unwrap`]: https://doc.rust-lang.org/core/result/enum.Result.html#method.unwrap
### خطوط جدید
در حال حاضر ، ما از خطوط جدید و کاراکتر هایی که دیگر در خط نمی گنجند چشم پوشی می کنیم. درعوض ما می خواهیم هر کاراکتر را یک خط به بالا منتقل کنیم (خط بالا حذف می شود) و دوباره از ابتدای آخرین خط شروع کنیم. برای انجام این کار ، ما یک پیاده سازی برای متد `new_line` در `Writer` اضافه می کنیم:
```rust
// in src/vga_buffer.rs
impl Writer {
fn new_line(&mut self) {
for row in 1..BUFFER_HEIGHT {
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col].read();
self.buffer.chars[row - 1][col].write(character);
}
}
self.clear_row(BUFFER_HEIGHT - 1);
self.column_position = 0;
}
fn clear_row(&mut self, row: usize) {/* TODO */}
}
```
ما تمام کاراکترهای صفحه را پیمایش می کنیم و هر کاراکتر را یک ردیف به بالا شیفت می دهیم. توجه داشته باشید که علامت گذاری دامنه (`..`) فاقد مقدار حد بالا است. ما همچنین سطر 0 را حذف می کنیم (اول محدوده از "1" شروع می شود) زیرا این سطر است که از صفحه به بیرون شیفت می شود.
برای تکمیل کد `newline` ، متد `clear_row` را اضافه می کنیم:
```rust
// in src/vga_buffer.rs
impl Writer {
fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ',
color_code: self.color_code,
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col].write(blank);
}
}
}
```
این متد با جایگزینی تمام کاراکترها با یک کاراکتر فاصله ، یک سطر را پاک می کند.
## یک رابط گلوبال
برای فراهم کردن یک نویسنده گلوبال که بتواند به عنوان رابط از سایر ماژول ها بدون حمل نمونه `Writer` در اطراف استفاده شود ، سعی می کنیم یک `WRITER` ثابت ایجاد کنیم:
```rust
// in src/vga_buffer.rs
pub static WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
```
با این حال ، اگر سعی کنیم اکنون آن را کامپایل کنیم ، خطاهای زیر رخ می دهد:
```
error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
--> src/vga_buffer.rs:7:17
|
7 | color_code: ColorCode::new(Color::Yellow, Color::Black),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0396]: raw pointers cannot be dereferenced in statics
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dereference of raw pointer in constant
error[E0017]: references in statics may only refer to immutable values
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
error[E0017]: references in statics may only refer to immutable values
--> src/vga_buffer.rs:8:13
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
```
برای فهمیدن آنچه در اینجا اتفاق می افتد ، باید بدانیم که ثابت ها(Statics) در زمان کامپایل مقداردهی اولیه می شوند ، برخلاف متغیرهای عادی که در زمان اجرا مقداردهی اولیه می شوند. مولفه‌ای(component) از کامپایلر Rust که چنین عبارات مقداردهی اولیه را ارزیابی می کند ، “[const evaluator]” نامیده می شود. عملکرد آن هنوز محدود است ، اما کارهای گسترده ای برای گسترش آن در حال انجام است ، به عنوان مثال در “[Allow panicking in constants]” RFC.
[const evaluator]: https://rustc-dev-guide.rust-lang.org/const-eval.html
[Allow panicking in constants]: https://github.com/rust-lang/rfcs/pull/2345
مسئله در مورد `ColorCode::new` با استفاده از توابع [`const` functions] قابل حل است ، اما مشکل اساسی اینجاست که Rust's const evaluator قادر به تبدیل اشاره‌گر‌های خام به رفرنس در زمان کامپایل نیست. شاید روزی جواب دهد ، اما تا آن زمان ، ما باید راه حل دیگری پیدا کنیم.
[`const` functions]: https://doc.rust-lang.org/reference/const_eval.html#const-functions
### استاتیک‌های تنبل (Lazy Statics)
یکبار مقداردهی اولیه استاتیک‌ها با توابع غیر ثابت یک مشکل رایج در راست است. خوشبختانه ، در حال حاضر راه حل خوبی در کرتی به نام [lazy_static] وجود دارد. این کرت ماکرو `lazy_static!` را فراهم می کند که یک `استاتیک` را با تنبلی مقدار‌دهی اولیه می کند. به جای محاسبه مقدار آن در زمان کامپایل ، `استاتیک` به تنبلی هنگام اولین دسترسی به آن، خود را مقداردهی اولیه می‌کند. بنابراین ، مقداردهی اولیه در زمان اجرا اتفاق می افتد تا کد مقدار دهی اولیه پیچیده و دلخواه امکان پذیر باشد.
[lazy_static]: https://docs.rs/lazy_static/1.0.1/lazy_static/
بیایید کرت `lazy_static` را به پروژه خود اضافه کنیم:
```toml
# in Cargo.toml
[dependencies.lazy_static]
version = "1.0"
features = ["spin_no_std"]
```
ما به ویژگی `spin_no_std` نیاز داریم ، زیرا به کتابخانه استاندارد پیوند نمی دهیم.
با استفاده از `lazy_static` ، می توانیم WRITER ثابت خود را بدون مشکل تعریف کنیم:
```rust
// in src/vga_buffer.rs
use lazy_static::lazy_static;
lazy_static! {
pub static ref WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
}
```
با این حال ، این `WRITER` بسیار بی فایده است زیرا غیر قابل تغییر است. این بدان معنی است که ما نمی توانیم چیزی در آن بنویسیم (از آنجا که همه متد های نوشتن `&mut self` را در ورودی میگیرند). یک راه حل ممکن استفاده از [استاتیک قابل تغییر] است. اما پس از آن هر خواندن و نوشتن آن ناامن (unsafe) است زیرا می تواند به راحتی باعث data race و سایر موارد بد باشد. استفاده از `static mut` بسیار نهی شده است ، حتی پیشنهادهایی برای [حذف آن][remove static mut] وجود داشت. اما گزینه های دیگر چیست؟ ما می توانیم سعی کنیم از یک استاتیک تغییرناپذیر با نوع سلول مانند [RefCell] یا حتی [UnsafeCell] استفاده کنیم که [تغییر پذیری داخلی] را فراهم می کند. اما این انواع [Sync] نیستند (با دلیل کافی) ، بنابراین نمی توانیم از آنها در استاتیک استفاده کنیم.
[استاتیک قابل تغییر]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
[remove static mut]: https://internals.rust-lang.org/t/pre-rfc-remove-static-mut/1437
[RefCell]: https://doc.rust-lang.org/book/ch15-05-interior-mutability.html#keeping-track-of-borrows-at-runtime-with-refcellt
[UnsafeCell]: https://doc.rust-lang.org/nightly/core/cell/struct.UnsafeCell.html
[تغییر پذیری داخلی]: https://doc.rust-lang.org/book/ch15-05-interior-mutability.html
[Sync]: https://doc.rust-lang.org/nightly/core/marker/trait.Sync.html
### Spinlocks
برای دستیابی به قابلیت تغییرپذیری داخلی همزمان (synchronized) ، کاربران کتابخانه استاندارد می توانند از [Mutex] استفاده کنند. هنگامی که منبع از قبل قفل شده است ، با مسدود کردن رشته ها ، امکان انحصار متقابل را فراهم می کند. اما هسته اصلی ما هیچ پشتیبانی از مسدود کردن یا حتی مفهومی از نخ ها ندارد ، بنابراین ما هم نمی توانیم از آن استفاده کنیم. با این وجود یک نوع کاملاً پایه‌ای از mutex در علوم کامپیوتر وجود دارد که به هیچ ویژگی سیستم عاملی نیاز ندارد: [spinlock]. به جای مسدود کردن ، نخ ها سعی می کنند آن را بارها و بارها در یک حلقه قفل کنند و بنابراین زمان پردازنده را می سوزانند تا دوباره mutex آزاد شود.
[Mutex]: https://doc.rust-lang.org/nightly/std/sync/struct.Mutex.html
[spinlock]: https://en.wikipedia.org/wiki/Spinlock
برای استفاده از spinning mutex ، می توانیم [کرت spin] را به عنوان یک وابستگی اضافه کنیم:
[کرت spin]: https://crates.io/crates/spin
```toml
# in Cargo.toml
[dependencies]
spin = "0.5.2"
```
سپس می توانیم از spinning Mutex برای افزودن [تغییر پذیری داخلی] امن به `WRITER` استاتیک خود استفاده کنیم:
```rust
// in src/vga_buffer.rs
use spin::Mutex;
...
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
```
اکنون می توانیم تابع `print_something` را حذف کرده و مستقیماً از تابع`_start` خود چاپ کنیم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
use core::fmt::Write;
vga_buffer::WRITER.lock().write_str("Hello again").unwrap();
write!(vga_buffer::WRITER.lock(), ", some numbers: {} {}", 42, 1.337).unwrap();
loop {}
}
```
برای اینکه بتوانیم از توابع آن استفاده کنیم ، باید تریت `fmt::Write` را وارد کنیم.
### ایمنی
توجه داشته باشید که ما فقط یک بلوک ناامن در کد خود داریم که برای ایجاد رفرنس `Buffer` با اشاره به `0xb8000` لازم است. پس از آن ، تمام عملیات ایمن هستند. Rust به طور پیش فرض از بررسی مرزها در دسترسی به آرایه استفاده می کند ، بنابراین نمی توانیم به طور اتفاقی خارج از بافر بنویسیم. بنابراین ، ما شرایط مورد نیاز را در سیستم نوع انجام می‌دهیم و قادر به ایجاد یک رابط ایمن به خارج هستیم.
### یک ماکروی println
اکنون که یک نویسنده گلوبال داریم ، می توانیم یک ماکرو `println` اضافه کنیم که می تواند از هر کجا در کد استفاده شود. [سینتکس ماکروی] راست کمی عجیب است ، بنابراین ما سعی نمی کنیم ماکرو را از ابتدا بنویسیم. در عوض به سورس [ماکروی `println!`] در کتابخانه استاندارد نگاه می کنیم:
[سینتکس ماکروی]: https://doc.rust-lang.org/nightly/book/ch19-06-macros.html#declarative-macros-with-macro_rules-for-general-metaprogramming
[ماکروی `println!`]: https://doc.rust-lang.org/nightly/std/macro.println!.html
```rust
#[macro_export]
macro_rules! println {
() => (print!("\n"));
($($arg:tt)*) => (print!("{}\n", format_args!($($arg)*)));
}
```
ماکروها از طریق یک یا چند قانون تعریف می شوند که شبیه بازوهای `match` هستند. ماکرو `println` دارای دو قانون است: اولین قانون برای فراخوانی های بدون آرگمان است (به عنوان مثال: `println!()`) ، که به `print!("\n")` گسترش می یابد، بنابراین فقط یک خط جدید را چاپ می کند. قانون دوم برای فراخوانی هایی با پارامترهایی مانند `println!("Hello")` یا `println!("Number: {}", 4)` است. همچنین با فراخوانی کل آرگومان ها و یک خط جدید `\n` اضافی در انتها ، به فراخوانی ماکرو `print!` گسترش می یابد.
ویژگی `#[macro_export]` ماکرو را برای کل کرت (نه فقط ماژولی که تعریف شده است) و کرت های خارجی در دسترس قرار می دهد. همچنین ماکرو را در ریشه کرت قرار می دهد ، به این معنی که ما باید ماکرو را به جای `std::macros::println` از طریق `use std::println` وارد کنیم.
[ماکرو `print!`] به این صورت تعریف می شود:
[ماکرو `print!`]: https://doc.rust-lang.org/nightly/std/macro.print!.html
```rust
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::io::_print(format_args!($($arg)*)));
}
```
ماکرو به فراخوانی [تابع `_print`] در ماژول `io` گسترش می یابد. [متغیر `$crate`] تضمین می کند که ماکرو هنگام گسترش در `std` در زمان استفاده در کرت های دیگر، در خارج از کرت `std` نیز کار می کند.
[ماکرو `format_args`] از آرگمان های داده شده یک نوع [fmt::Arguments] را می سازد که به `_print` ارسال می شود. [تابع `_print`] از کتابخانه استاندارد،`print_to` را فراخوانی می کند ، که بسیار پیچیده است زیرا از دستگاه های مختلف `Stdout` پشتیبانی می کند. ما به این پیچیدگی احتیاج نداریم زیرا فقط می خواهیم در بافر VGA چاپ کنیم.
[تابع `_print`]: https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698
[متغیر `$crate`]: https://doc.rust-lang.org/1.30.0/book/first-edition/macros.html#the-variable-crate
[ماکرو `format_args`]: https://doc.rust-lang.org/nightly/std/macro.format_args.html
[fmt::Arguments]: https://doc.rust-lang.org/nightly/core/fmt/struct.Arguments.html
برای چاپ در بافر VGA ، ما فقط ماکروهای `println!` و `print!` را کپی می کنیم ، اما آنها را اصلاح می کنیم تا از تابع `_print` خود استفاده کنیم:
```rust
// in src/vga_buffer.rs
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
چیزی که ما از تعریف اصلی `println` تغییر دادیم این است که فراخوانی ماکرو `print!` را با پیشوند `$crate` انجام می دهیم. این تضمین می کند که اگر فقط می خواهیم از `println` استفاده کنیم ، نیازی به وارد کردن ماکرو `print!` هم نداشته باشیم.
مانند کتابخانه استاندارد ، ویژگی `#[macro_export]` را به هر دو ماکرو اضافه می کنیم تا در همه جای کرت ما در دسترس باشند. توجه داشته باشید که این ماکروها را در فضای نام ریشه کرت قرار می دهد ، بنابراین وارد کردن آنها از طریق `use crate::vga_buffer::println` کار نمی کند. در عوض ، ما باید `use crate::println` را استفاده کنیم.
تابع `_print` نویسنده (`WRITER`) استاتیک ما را قفل می کند و متد`write_fmt` را روی آن فراخوانی می کند. این متد از تریت `Write` است ، ما باید این تریت را وارد کنیم. اگر چاپ موفقیت آمیز نباشد ، `unwrap()` اضافی در انتها باعث پنیک می‌شود. اما از آنجا که ما همیشه `Ok` را در `write_str` برمی گردانیم ، این اتفاق نمی افتد.
از آنجا که ماکروها باید بتوانند از خارج از ماژول، `_print` را فراخوانی کنند، تابع باید عمومی (public) باشد. با این حال ، از آنجا که این جزئیات پیاده سازی را خصوصی (private) در نظر می گیریم، [ویژگی `doc(hidden)`] را اضافه می کنیم تا از مستندات تولید شده پنهان شود.
[ویژگی `doc(hidden)`]: https://doc.rust-lang.org/nightly/rustdoc/write-documentation/the-doc-attribute.html#hidden
### Hello World توسط `println`
اکنون می توانیم از `println` در تابع `_start` استفاده کنیم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() {
println!("Hello World{}", "!");
loop {}
}
```
توجه داشته باشید که ما مجبور نیستیم ماکرو را در تابع اصلی وارد کنیم ، زیرا در حال حاضر در فضای نام ریشه موجود است.
همانطور که انتظار می رفت ، اکنون یک _“Hello World!”_ روی صفحه مشاهده می کنیم:
![QEMU printing “Hello World!”](vga-hello-world.png)
### چاپ پیام های پنیک
اکنون که ماکرو `println` را داریم ، می توانیم از آن در تابع پنیک برای چاپ پیام و مکان پنیک استفاده کنیم:
```rust
// in main.rs
/// This function is called on panic.
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
```
اکنون وقتی که `panic!("Some panic message");` را در تابع `_start` خود اضافه میکنیم ، خروجی زیر را می گیریم:
![QEMU printing “panicked at 'Some panic message', src/main.rs:28:5](vga-panic.png)
بنابراین ما نه تنها می‌دانیم که یک پنیک رخ داده است ، بلکه پیام پنیک و اینکه در کجای کد رخ داده است را نیز می‌دانیم.
## خلاصه
در این پست با ساختار بافر متن VGA و نحوه نوشتن آن از طریق نگاشت حافظه در آدرس `0xb8000` آشنا شدیم. ما یک ماژول راست ایجاد کردیم که عدم امنیت نوشتن را در این بافر نگاشت حافظه شده را محصور می کند و یک رابط امن و راحت به خارج ارائه می دهد.
همچنین دیدیم که به لطف کارگو ، اضافه کردن وابستگی به کتابخانه های دیگران چقدر آسان است. دو وابستگی که اضافه کردیم ، `lazy_static` و`spin` ، در توسعه سیستم عامل بسیار مفید هستند و ما در پست های بعدی از آنها در مکان های بیشتری استفاده خواهیم کرد.
## بعدی چیست؟
در پست بعدی نحوه راه اندازی چارچوب تست واحد (Unit Test) راست توضیح داده شده است. سپس از این پست چند تست واحد اساسی برای ماژول بافر VGA ایجاد خواهیم کرد.

View File

@@ -1,715 +0,0 @@
+++
title = "VGAテキストモード"
weight = 3
path = "ja/vga-text-mode"
date = 2018-02-26
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "bd6fbcb1c36705b2c474d7fcee387bfea1210851"
# GitHub usernames of the people that translated this post
translators = ["woodyZootopia", "JohnTitor"]
+++
[VGAテキストモード][VGA text mode]は画面にテキストを出力するシンプルな方法です。この記事では、すべてのunsafeな要素を別のモジュールにカプセル化することで、それを安全かつシンプルに扱えるようにするインターフェースを作ります。また、Rustの[フォーマッティングマクロ][formatting macros]のサポートも実装します。
[VGA text mode]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
[formatting macros]: https://doc.rust-lang.org/std/fmt/#related-macros
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-03` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-03
<!-- toc -->
## VGAテキストバッファ
VGAテキストモードにおいて、文字を画面に出力するには、VGAハードウェアのテキストバッファにそれを書き込まないといけません。VGAテキストバッファは、普通25行と80列からなる2次元配列で、画面に直接書き出されます。それぞれの配列の要素は画面上の一つの文字を以下の形式で表現しています
ビット | 値
------ | ----------------
0-7 | ASCII コードポイント
8-11 | フォアグラウンド(前景)色
12-14 | バックグラウンド(背景)色
15 | 点滅
最初の1バイトは、出力されるべき文字を[ASCIIエンコーディング][ASCII encoding]で表します。正確に言うと、完全にASCIIではなく、[コードページ437][_code page 437_]という、いくつか文字が追加され、軽微な修正のなされたものです。簡単のため、この記事ではASCII文字と呼ぶことにします。
[ASCII encoding]: https://ja.wikipedia.org/wiki/ASCII
[_code page 437_]: https://ja.wikipedia.org/wiki/コードページ437
2つ目のバイトはその文字がどのように出力されるのかを定義します。最初の4ビットが前景色訳注文字自体の色を、次の3ビットが背景色を、最後のビットがその文字が点滅するのかを決めます。以下の色を使うことができます
数字 | 色 | 数字 + Bright Bit | <ruby>Bright<rp> (</rp><rt>明るい</rt><rp>) </rp></ruby> 色
------ | ---------- | ------------------- | -------------
0x0 | 黒 | 0x8 | 暗いグレー
0x1 | 青 | 0x9 | 明るい青
0x2 | 緑 | 0xa | 明るい緑
0x3 | シアン | 0xb | 明るいシアン
0x4 | 赤 | 0xc | 明るい赤
0x5 | マゼンタ | 0xd | ピンク
0x6 | 茶色 | 0xe | 黄色
0x7 | 明るいグレー| 0xf | 白
4ビット目は **bright bit** で、これは1になっているときたとえば青を明るい青に変えます。背景色については、このビットは点滅ビットとして再利用されています。
VGAテキストバッファはアドレス`0xb8000`に[<ruby>memory-mapped<rp> (</rp><rt>メモリマップされた</rt><rp>) </rp></ruby> I/O][memory-mapped I/O]を通じてアクセスできます。これは、このアドレスへの読み書きをしても、RAMではなく直接VGAハードウェアのテキストバッファにアクセスするということを意味します。つまり、このアドレスに対する通常のメモリ操作を通じて、テキストバッファを読み書きできるのです。
[memory-mapped I/O]: https://ja.wikipedia.org/wiki/メモリマップドI/O
メモリマップされたハードウェアは通常のRAM操作すべてをサポートしてはいないかもしれないということに注意してください。たとえば、デバイスはバイトずつの読み取りしかサポートしておらず、`u64`が読まれるとゴミデータを返すかもしれません。ありがたいことに、テキストバッファを特別なやり方で取り扱う必要がないよう、テキストバッファは[通常の読み書きをサポートしています][supports normal reads and writes]。
[supports normal reads and writes]: https://web.stanford.edu/class/cs140/projects/pintos/specs/freevga/vga/vgamem.htm#manip
## Rustのモジュール
VGAバッファが動く仕組みを学んだので、さっそく画面出力を扱うRustのモジュールを作っていきます。
```rust
//in src/main.rs
mod vga_buffer;
```
このモジュールの中身のために、新しい`src/vga_buffer.rs`というファイルを作ります。このファイル以下のコードは、(そうならないよう指定されない限り)すべてこの新しいモジュールの中に入ります。
### 色
まず、様々な色をenumを使って表しましょう
```rust
// in src/vga_buffer.rs
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
```
ここでは、それぞれの色の数を指定するのに[C言語ライクなenum][C-like enum]を使っています。`repr(u8)`属性のため、それぞれのenumのヴァリアントは`u8`として格納されています。実際には4ビットでも十分なのですが、Rustには`u4`型はありませんので。
[C-like enum]: https://doc.rust-jp.rs/rust-by-example-ja/custom_types/enum/c_like.html
通常、コンパイラは使われていないヴァリアントそれぞれに対して警告を発します。`#[allow(dead_code)]`属性を使うことで`Color` enumに対するそれらの警告を消すことができます。
[`Copy`]、[`Clone`]、[`Debug`]、[`PartialEq`]、および [`Eq`]を[derive][deriving]することによって、この型の[コピーセマンティクス][copy semantics]を有効化し、この型を出力することと比較することを可能にします。
[deriving]: https://doc.rust-jp.rs/rust-by-example-ja/trait/derive.html
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[`Clone`]: https://doc.rust-lang.org/nightly/core/clone/trait.Clone.html
[`Debug`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Debug.html
[`PartialEq`]: https://doc.rust-lang.org/nightly/core/cmp/trait.PartialEq.html
[`Eq`]: https://doc.rust-lang.org/nightly/core/cmp/trait.Eq.html
[copy semantics]: https://doc.rust-jp.rs/book-ja/appendix-03-derivable-traits.html#値を複製するcloneとcopy
前景と背景の色を指定する完全なカラーコードを表現するために、`u8`の上に[ニュータイプ][newtype]を作ります。
[newtype]: https://doc.rust-jp.rs/rust-by-example-ja/generics/new_types.html
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
```
`ColorCode`構造体は前景色と背景色を持つので、完全なカラーコードを持ちます。前と同じように、`Copy``Debug`トレイトをこれにderiveします。`ColorCode``u8`と全く同じデータ構造を持つようにするために、[`repr(transparent)`]属性(訳注:翻訳当時、リンク先未訳)を使います。
[`repr(transparent)`]: https://doc.rust-lang.org/nomicon/other-reprs.html#reprtransparent
### テキストバッファ
次に、画面上の文字とテキストバッファをそれぞれ表す構造体を追加していきます。
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
#[repr(transparent)]
struct Buffer {
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
Rustにおいて、デフォルトの構造体におけるフィールドの並べ方は未定義なので、[`repr(C)`]属性が必要になります。これは、構造体のフィールドがCの構造体と全く同じように並べられることを保証してくれるので、フィールドの並べ方が正しいと保証してくれるのです。`Buffer`構造体については、[`repr(transparent)`]をもう一度使うことで、その唯一のフィールドと同じメモリレイアウトを持つようにしています。
[`repr(C)`]: https://doc.rust-jp.rs/rust-nomicon-ja/other-reprs.html#reprc
実際に画面に書き出すため、writer型を作ります。
```rust
// in src/vga_buffer.rs
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
```
writerは常に最後の行に書き、行が一杯になったときもしくは`\n`を受け取った時は1行上に送ります。`column_position`フィールドは、最後の行における現在の位置を持ちます。現在の前景および背景色は`color_code`によって指定されており、VGAバッファへの参照は`buffer`に格納されています。ここで、コンパイラにどのくらいの間参照が有効であるのかを教えるために[明示的なライフタイム][explicit lifetime]が必要になることに注意してください。[`'static`]ライフタイムは、その参照がプログラムの実行中ずっと有効であることを指定していますこれはVGAバッファについて正しいです
[explicit lifetime]: https://doc.rust-jp.rs/book-ja/ch10-03-lifetime-syntax.html#ライフタイム注釈記法
[`'static`]: https://doc.rust-jp.rs/book-ja/ch10-03-lifetime-syntax.html#静的ライフタイム
### 出力する
では`Writer`を使ってバッファの文字を変更しましょう。まず一つのASCII文字を書くメソッドを作ります
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
self.buffer.chars[row][col] = ScreenChar {
ascii_character: byte,
color_code,
};
self.column_position += 1;
}
}
}
fn new_line(&mut self) {/* TODO */}
}
```
(引数の)バイトが[改行コード][newline]のバイトすなわち`\n`の場合は、writerは何も出力しません。代わりに、あとで実装する`new_line`メソッドを呼びます。他のバイトは、2つ目のマッチケースにおいて画面に出力されます。
[newline]: https://ja.wikipedia.org/wiki/%E6%94%B9%E8%A1%8C%E3%82%B3%E3%83%BC%E3%83%89
バイトを出力する時、writerは現在の行がいっぱいかをチェックします。その場合、行を折り返すために先に`new_line`の呼び出しが必要です。その後で現在の場所のバッファに新しい`ScreenChar`を書き込みます。最後に、現在の<ruby>列の位置<rp> (</rp><rt>column position</rt><rp>) </rp></ruby>を進めます。
文字列全体を出力するには、バイト列に変換しひとつひとつ出力すればよいです:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
// 出力可能なASCIIバイトか、改行コード
0x20..=0x7e | b'\n' => self.write_byte(byte),
// 出力可能なASCIIバイトではない
_ => self.write_byte(0xfe),
}
}
}
}
```
VGAテキストバッファはASCIIおよび[コードページ437][code page 437]にある追加のバイトのみをサポートしています。Rustの文字列はデフォルトでは[UTF-8]なのでVGAテキストバッファにはサポートされていないバイトを含んでいる可能性があります。matchを使って出力可能なASCIIバイト改行コードか、空白文字から`~`文字の間のすべての文字)と出力不可能なバイトを分けています。出力不可能なバイトについては、文字`■`を出力しますこれはVGAハードウェアにおいて16進コード`0xfe`を持っています)。
[code page 437]: https://ja.wikipedia.org/wiki/コードページ437
[UTF-8]: https://www.fileformat.info/info/unicode/utf8.htm
#### やってみよう!
適当な文字を画面に書き出すために、一時的に使う関数を作ってみましょう。
```rust
// in src/vga_buffer.rs
pub fn print_something() {
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello ");
writer.write_string("Wörld!");
}
```
この関数はまず、VGAバッファの`0xb8000`を指す新しいwriterを作ります。このための構文はやや奇妙に思われるかもしれませんまず、整数`0xb8000`を可変な[生ポインタ][raw pointer]にキャストします。次にこれを(`*`を使って)参照外しすることで可変な参照に変え、即座にそれを(`&mut`を使って)再び借用します。コンパイラはこの生ポインタが有効であることを保証できないので、この変換には[`unsafe`ブロック][`unsafe` block]が必要となります。
[raw pointer]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html#生ポインタを参照外しする
[`unsafe` block]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html
つぎに、この関数はそれにバイト`b'H'`を書きます。`b`というプレフィックスは、ASCII文字を表す[バイトリテラル][byte literal]を作ります。文字列`"ello "``"Wörld!"`を書くことで、私達の`write_string`関数と出力不可能な文字の処理をテストできます。出力を見るためには、`print_something`関数を`_start`関数から呼び出さなければなりません:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
vga_buffer::print_something();
loop {}
}
```
ここで、私達のプロジェクトを実行したら、`Hello W■■rld!`が画面の左 **下** に黄色で出力されるはずです。
[byte literal]: https://doc.rust-lang.org/reference/tokens.html#byte-literals
![QEMU output with a yellow `Hello W■■rld!` in the lower left corner](vga-hello.png)
`ö`は2つの`■`という文字として出力されていることに注目してください。これは、`ö`は[UTF-8]において2つのバイトで表され、それらはどちらも出力可能なASCIIの範囲に収まっていないためです。実は、これはUTF-8の基本的な特性ですマルチバイト値のそれぞれのバイトは、絶対に有効なASCIIではないのです。
### Volatile
メッセージが正しく出力されるのを確認できました。しかし、より強力に最適化をする将来のRustコンパイラでは、これはうまく行かないかもしれません。
問題なのは、私達は`Buffer`に書き込むけれども、それから読み取ることはないということです。コンパイラは私達が実際には通常のRAMの代わりにVGAバッファメモリにアクセスしていることを知らないので、文字が画面に出力されるという副作用も全く知りません。なので、それらの書き込みは不要で省略可能と判断するかもしれません。この誤った最適化を回避するためには、それらの書き込みを **[volatile]** であると指定する必要があります。これは、この書き込みには副作用があり、最適化により取り除かれるべきではないとコンパイラに命令します。
[volatile]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
VGAバッファへのvolatileな書き込みをするために、[volatile][volatile crate]ライブラリを使います。この **クレート**Rustではパッケージのことをこう呼びますは、`read``write`というメソッドを持つ`Volatile`というラッパー型を提供します。これらのメソッドは、内部的にcoreライブラリの[read_volatile]と[write_volatile]関数を使い、読み込み・書き込みが最適化により取り除かれないことを保証します。
[volatile crate]: https://docs.rs/volatile
[read_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.read_volatile.html
[write_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.write_volatile.html
`Cargo.toml``dependencies`セクションに`volatile`クレートを追加することで、このクレートへの依存関係を追加できます。
```toml
# in Cargo.toml
[dependencies]
volatile = "0.2.6"
```
`0.2.6`は[セマンティック][semantic]バージョン番号です。詳しくは、cargoドキュメントの[依存関係の指定][Specifying Dependencies]を見てください。
[semantic]: https://semver.org/lang/ja/
[Specifying Dependencies]: https://doc.crates.io/specifying-dependencies.html
これを使って、VGAバッファへの書き込みをvolatileにしてみましょう。`Buffer`型を以下のように変更します:
```rust
// in src/vga_buffer.rs
use volatile::Volatile;
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
`ScreenChar`の代わりに、`Volatile<ScreenChar>`を使っています(`Volatile`型は[ジェネリック][generic]であり(ほぼ)すべての型をラップできます)。これにより、間違って「普通の」書き込みをこれに対して行わないようにできます。これからは、代わりに`write`メソッドを使わなければいけません。
[generic]: https://doc.rust-lang.org/book/ch10-01-syntax.html
つまり、`Writer::write_byte`メソッドを更新しなければいけません:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
...
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code,
});
...
}
}
}
...
}
```
`=`を使った通常の代入の代わりに`write`メソッドを使っています。これにより、コンパイラがこの書き込みを最適化して取り除いてしまわないことが保証されます。
### フォーマットマクロ
Rustの<ruby>フォーマットマクロ<rp> (</rp><rt>formatting macro</rt><rp>) </rp></ruby>もサポートすると良さそうです。そうすると、整数や浮動小数点数といった様々な型を簡単に出力できます。それらをサポートするためには、[`core::fmt::Write`]トレイトを実装する必要があります。このトレイトに必要なメソッドは`write_str`だけです。これは私達の`write_string`によく似ており、戻り値の型が`fmt::Result`であるだけです:
[`core::fmt::Write`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Write.html
```rust
// in src/vga_buffer.rs
use core::fmt;
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
```
`Ok(())`は、`()`型を持つ`Ok`、というだけです。
Rustの組み込みの`write!`/`writeln!`フォーマットマクロが使えるようになりました。
```rust
// in src/vga_buffer.rs
pub fn print_something() {
use core::fmt::Write;
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello! ");
write!(writer, "The numbers are {} and {}", 42, 1.0/3.0).unwrap();
}
```
このようにすると、画面の下端に`Hello! The numbers are 42 and 0.3333333333333333`が見えるはずです。`write!`の呼び出しは`Result`を返し、これは放置されると警告を出すので、[`unwrap`]関数エラーの際パニックしますをこれに呼び出しています。VGAバッファへの書き込みは絶対に失敗しないので、この場合これは問題ではありません。
[`unwrap`]: https://doc.rust-lang.org/core/result/enum.Result.html#method.unwrap
### 改行
現在、改行や、行に収まらない文字は無視しています。その代わりに、すべての文字を一行上に持っていき(一番上の行は消去されます)、前の行の最初から始めるようにしたいです。これをするために、`Writer``new_line`というメソッドの実装を追加します。
```rust
// in src/vga_buffer.rs
impl Writer {
fn new_line(&mut self) {
for row in 1..BUFFER_HEIGHT {
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col].read();
self.buffer.chars[row - 1][col].write(character);
}
}
self.clear_row(BUFFER_HEIGHT - 1);
self.column_position = 0;
}
fn clear_row(&mut self, row: usize) {/* TODO */}
}
```
すべての画面の文字をイテレートし、それぞれの文字を一行上に動かします。範囲記法 (`..`) は上端を含まないことに注意してください。また、0行目はシフトしたら画面から除かれるので、この行についても省いています最初の範囲は`1`から始まっています)。
newlineのプログラムを完成させるには、`clear_row`メソッドを追加すればよいです:
```rust
// in src/vga_buffer.rs
impl Writer {
fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ',
color_code: self.color_code,
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col].write(blank);
}
}
}
```
このメソッドはすべての文字を空白文字で書き換えることによって行をクリアしてくれます。
## <ruby>大域的<rp> (</rp><rt>global</rt><rp>) </rp></ruby>なインターフェース
`Writer`のインスタンスを動かさずとも他のモジュールからインターフェースとして使える、大域的なwriterを提供するために、<ruby>静的<rp> (</rp><rt>static</rt><rp>) </rp></ruby>な`WRITER`を作りましょう:
```rust
// in src/vga_buffer.rs
pub static WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
```
しかし、これをコンパイルしようとすると、次のエラーが起こります:
```
error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
(エラー[E0015]: static内における呼び出しは、定数関数、タプル構造体、タプルヴァリアントに限定されています
--> src/vga_buffer.rs:7:17
|
7 | color_code: ColorCode::new(Color::Yellow, Color::Black),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0396]: raw pointers cannot be dereferenced in statics
(エラー[E0396]: 生ポインタはstatic内では参照外しできません
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dereference of raw pointer in constant
| (定数内での生ポインタの参照外し)
error[E0017]: references in statics may only refer to immutable values
(エラー[E0017]: static内における参照が参照してよいのは不変変数だけです
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
| staticは不変変数を必要とします
error[E0017]: references in statics may only refer to immutable values
(エラー[E0017]: static内における参照が参照してよいのは不変変数だけです
--> src/vga_buffer.rs:8:13
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
| staticは不変変数を必要とします
```
何が起こっているかを理解するには、実行時に初期化される通常の変数とは対照的に、静的変数はコンパイル時に初期化されるということを知らないといけません。この初期化表現を評価するRustコンパイラのコンポーネントを"[const evaluator]"といいます。この機能はまだ限定的ですが、「[定数内でpanicできるようにする][Allow panicking in constants]」RFCのように、この機能を拡張する作業が現在も進行しています。
[const evaluator]: https://rustc-dev-guide.rust-lang.org/const-eval.html
[Allow panicking in constants]: https://github.com/rust-lang/rfcs/pull/2345
`ColorCode::new`に関する問題は[`const`関数][`const` functions]を使って解決できるかもしれませんが、ここでの根本的な問題は、Rustのconst evaluatorがコンパイル時に生ポインタを参照へと変えることができないということです。いつかうまく行くようになるのかもしれませんが、その時までは、別の方法を行わなければなりません。
[`const` functions]: https://doc.rust-lang.org/reference/const_eval.html#const-functions
### <ruby>怠けた<rp> (</rp><rt>Lazy</rt><rp>) </rp></ruby>静的変数
定数でない関数で一度だけ静的変数を初期化したい、というのはRustにおいてよくある問題です。嬉しいことに、[lazy_static]というクレートにすでに良い解決方法が存在します。このクレートは、初期化が後回しにされる`static`を定義する`lazy_static!`マクロを提供します。その値をコンパイル時に計算する代わりに、この`static`は最初にアクセスされたときに初めて初期化します。したがって、初期化は実行時に起こるので、どんなに複雑な初期化プログラムも可能ということです。
<div class="note">
**訳注:** lazyは、普通「遅延評価」などと訳されます。「怠けているので、アクセスされるギリギリまで評価されない」という英語のイメージを伝えたかったので上のように訳してみました。
</div>
[lazy_static]: https://docs.rs/lazy_static/1.0.1/lazy_static/
私達のプロジェクトに`lazy_static`クレートを追加しましょう:
```toml
# in Cargo.toml
[dependencies.lazy_static]
version = "1.0"
features = ["spin_no_std"]
```
標準ライブラリをリンクしないので、`spin_no_std`機能が必要です。
`lazy_static`を使えば、静的な`WRITER`が問題なく定義できます:
```rust
// in src/vga_buffer.rs
use lazy_static::lazy_static;
lazy_static! {
pub static ref WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
}
```
しかし、この`WRITER`は<ruby>不変<rp> (</rp><rt>immutable</rt><rp>) </rp></ruby>なので、全く使い物になりません。なぜならこれは、この`WRITER`に何も書き込めないということを意味するからです(私達のすべての書き込みメソッドは`&mut self`を取るからです)。ひとつの解決策には、[<ruby>可変<rp> (</rp><rt>mutable</rt><rp>) </rp></ruby>で静的な変数][mutable static]を使うということがあります。しかし、そうすると、あらゆる読み書きが容易にデータ競合やその他の良くないことを引き起こしてしまうので、それらがすべてunsafeになってしまいます。`static mut`を使うことも、[それを削除しようという提案][remove static mut]すらあることを考えると、できる限り避けたいです。しかし他に方法はあるのでしょうか?不変静的変数を[RefCell]や、果ては[UnsafeCell]のような、[<ruby>内部可変性<rp> (</rp><rt>interior mutability</rt><rp>) </rp></ruby>][interior mutability]を提供するcell型と一緒に使うという事も考えられます。しかし、それらの型はちゃんとした理由があって[Sync]ではないので、静的変数で使うことはできません。
[mutable static]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html#可変で静的な変数にアクセスしたり変更する
[remove static mut]: https://internals.rust-lang.org/t/pre-rfc-remove-static-mut/1437
[RefCell]: https://doc.rust-jp.rs/book-ja/ch15-05-interior-mutability.html#refcelltで実行時に借用を追いかける
[UnsafeCell]: https://doc.rust-lang.org/nightly/core/cell/struct.UnsafeCell.html
[interior mutability]: https://doc.rust-jp.rs/book-ja/ch15-05-interior-mutability.html
[Sync]: https://doc.rust-lang.org/nightly/core/marker/trait.Sync.html
### スピンロック
同期された内部可変性を得るためには、標準ライブラリを使えるなら[Mutex]を使うことができます。これは、リソースがすでにロックされていた場合、スレッドをブロックすることにより相互排他性を提供します。しかし、私達の初歩的なカーネルにはブロックの機能はもちろんのこと、スレッドの概念すらないので、これも使うことはできません。しかし、コンピュータサイエンスの世界には、OSを必要としない非常に単純なmutexが存在するのですそれが[<ruby>スピンロック<rp> (</rp><rt>spinlock</rt><rp>) </rp></ruby>][spinlock]です。スピンロックを使うと、ブロックする代わりに、スレッドは単純にリソースを何度も何度もロックしようとすることで、mutexが開放されるまでの間CPU時間を使い尽くします。
[Mutex]: https://doc.rust-lang.org/nightly/std/sync/struct.Mutex.html
[spinlock]: https://ja.wikipedia.org/wiki/スピンロック
スピンロックによるmutexを使うには、[spinクレート][spin crate]への依存を追加すればよいです:
[spin crate]: https://crates.io/crates/spin
```toml
# in Cargo.toml
[dependencies]
spin = "0.5.2"
```
すると、スピンを使ったMutexを使うことができ、静的な`WRITER`に安全な[内部可変性][interior mutability]を追加できます。
```rust
// in src/vga_buffer.rs
use spin::Mutex;
...
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
```
`print_something`関数を消して、`_start`関数から直接出力しましょう:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
use core::fmt::Write;
vga_buffer::WRITER.lock().write_str("Hello again").unwrap();
write!(vga_buffer::WRITER.lock(), ", some numbers: {} {}", 42, 1.337).unwrap();
loop {}
}
```
`fmt::Write`トレイトの関数を使うためには、このトレイトをインポートする必要があります。
### 安全性
コードにはunsafeブロックが一つ`0xb8000`を指す参照`Buffer`を作るために必要なもの)しかないことに注目してください。その後は、すべての命令が<ruby>安全<rp> (</rp><rt>safe</rt><rp>) </rp></ruby>です。Rustは配列アクセスにはデフォルトで境界チェックを行うので、間違ってバッファの外に書き込んでしまうことはありえません。よって、必要とされる条件を型システムにすべて組み込んだので、安全なインターフェースを外部に提供できます。
### printlnマクロ
大域的なwriterを手に入れたので、プログラムのどこでも使える`println`マクロを追加できます。Rustの[マクロの構文][macro syntax]はすこしややこしいので、一からマクロを書くことはしません。代わりに、標準ライブラリで[`println!`マクロ][`println!` macro]のソースを見てみます:
[macro syntax]: https://doc.rust-lang.org/nightly/book/ch19-06-macros.html#declarative-macros-with-macro_rules-for-general-metaprogramming
[`println!` macro]: https://doc.rust-lang.org/nightly/std/macro.println!.html
```rust
#[macro_export]
macro_rules! println {
() => (print!("\n"));
($($arg:tt)*) => (print!("{}\n", format_args!($($arg)*)));
}
```
マクロは1つ以上のルールを使って定義されます`match`アームと似ていますね)。`println`には2つのルールがあります1つ目は引数なし呼び出し例えば `println!()`)のためのもので、これは`print!("\n")`に展開され、よってただ改行を出力するだけになります。2つ目のルールはパラメータ付きの呼び出し例えば`println!("Hello")``println!("Number: {}", 4)`)のためのものです。これも`print!`マクロの呼び出しへと展開され、すべての引数に加え、改行`\n`を最後に追加して渡します。
`#[macro_export]`属性はマクロを(その定義されたモジュールだけではなく)クレート全体および外部クレートで使えるようにします。また、これはマクロをクレートルートに置くため、`std::macros::println`の代わりに`use std::println`を使ってマクロをインポートしないといけないということを意味します。
[`print!`マクロ][`print!` macro]は以下のように定義されています:
[`print!` macro]: https://doc.rust-lang.org/nightly/std/macro.print!.html
```rust
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::io::_print(format_args!($($arg)*)));
}
```
このマクロは`io`モジュール内の[`_print`関数][`_print` function]の呼び出しへと展開しています。[`$crate`という変数][`$crate` variable]は、他のクレートで使われた際、`std`へと展開することによって、マクロが`std`クレートの外側で使われたとしてもうまく動くようにしてくれます。
[`format_args`マクロ][`format_args` macro]が与えられた引数から[fmt::Arguments]型を作り、これが`_print`へと渡されています。libstdの[`_print`関数]は`print_to`を呼び出すのですが、これは様々な`Stdout`デバイスをサポートいているためかなり煩雑です。ここではただVGAバッファに出力したいだけなので、そのような煩雑な実装は必要ありません。
[`_print` function]: https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698
[`$crate` variable]: https://doc.rust-lang.org/1.30.0/book/first-edition/macros.html#the-variable-crate
[`format_args` macro]: https://doc.rust-lang.org/nightly/std/macro.format_args.html
[fmt::Arguments]: https://doc.rust-lang.org/nightly/core/fmt/struct.Arguments.html
VGAバッファに出力するには、`println!`マクロと`print!`マクロをコピーし、独自の`_print`関数を使うように修正してやればいいです:
```rust
// in src/vga_buffer.rs
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
元の`println`の定義と異なり、`print!`マクロの呼び出しにも`$crate`をつけるようにしています。これにより、`println`だけを使いたいと思ったら`print!`マクロもインポートしなくていいようになります。
標準ライブラリのように、`#[macro_export]`属性を両方のマクロに与え、クレートのどこでも使えるようにします。このようにすると、マクロはクレートの名前空間のルートに置かれるので、`use crate::vga_buffer::println`としてインポートするとうまく行かないことに注意してください。代わりに、 `use crate::println`としなければいけません。
`_print`関数は静的な`WRITER`をロックし、その`write_fmt`メソッドを呼び出します。このメソッドは`Write`トレイトのものなので、このトレイトもインポートしないといけません。最後に追加した`unwrap()`は、画面出力がうまく行かなかったときパニックします。しかし、`write_str`は常に`Ok`を返すようにしているので、これは起きないはずです。
マクロは`_print`をモジュールの外側から呼び出せる必要があるので、この関数は<ruby>公開<rp> (</rp><rt>public</rt><rp>) </rp></ruby>されていなければなりません。しかし、これは<ruby>非公開<rp> (</rp><rt>private</rt><rp>) </rp></ruby>の実装の詳細であると考え、[`doc(hidden)`属性][`doc(hidden)` attribute]をつけることで、生成されたドキュメントから隠すようにします。
[`doc(hidden)` attribute]: https://doc.rust-lang.org/nightly/rustdoc/write-documentation/the-doc-attribute.html#hidden
### `println`を使ってHello World
こうすることで、`_start`関数で`println`を使えるようになります:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() {
println!("Hello World{}", "!");
loop {}
}
```
マクロはすでに名前空間のルートにいるので、main関数内でマクロをインポートしなくても良いということに注意してください。
期待通り、画面に Hello World! と出ています:
![QEMU printing “Hello World!”](vga-hello-world.png)
### パニックメッセージを出力する
`println`マクロを手に入れたので、これを私達のパニック関数で使って、パニックメッセージとパニックの場所を出力させることができます:
```rust
// in main.rs
/// この関数はパニック時に呼ばれる。
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
```
`panic!("Some panic message");`という文を`_start`関数に書くと、次の出力を得ます:
![QEMU printing “panicked at 'Some panic message', src/main.rs:28:5](vga-panic.png)
つまり、パニックが起こったということだけでなく、パニックメッセージとそれがコードのどこで起こったかまで知ることができます。
## まとめ
この記事では、VGAテキストバッファの構造と、どのようにすれば`0xb8000`番地におけるメモリマッピングを通じてそれに書き込みを行えるかを学びました。このメモリマップされたバッファへの書き込みというunsafeな操作をカプセル化し、安全で便利なインターフェースを外部に提供するRustモジュールを作りました。
また、cargoのおかげでサードパーティのライブラリへの依存関係を簡単に追加できることも分かりました。`lazy_static``spin`という2つの依存先は、OS開発においてとても便利であり、今後の記事においても使っていきます。
## 次は?
次の記事ではRustに組み込まれている単体テストフレームワークをセットアップする方法を説明します。その後、この記事のVGAバッファモジュールに対する基本的な単体テストを作ります。

View File

@@ -1,703 +0,0 @@
+++
title = "VGA 텍스트 모드"
weight = 3
path = "ko/vga-text-mode"
date = 2018-02-26
[extra]
chapter = "Bare Bones"
# Please update this when updating the translation
translation_based_on_commit = "1c9b5edd6a5a667e282ca56d6103d3ff1fd7cfcb"
# GitHub usernames of the people that translated this post
translators = ["JOE1994", "Quqqu"]
+++
[VGA 텍스트 모드][VGA text mode]를 통해 쉽게 화면에 텍스트를 출력할 수 있습니다. 이 글에서는 안전하지 않은 작업들을 분리된 모듈에 격리해 쉽고 안전하게 VGA 텍스트 모드를 이용할 수 있는 인터페이스를 구현합니다. 또한 Rust의 [서식 정렬 매크로 (formatting macro)][formatting macros]에 대한 지원을 추가할 것입니다.
[VGA text mode]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
[formatting macros]: https://doc.rust-lang.org/std/fmt/#related-macros
<!-- more -->
이 블로그는 [GitHub 저장소][GitHub]에서 오픈 소스로 개발되고 있으니, 문제나 문의사항이 있다면 저장소의 'Issue' 기능을 이용해 제보해주세요. [페이지 맨 아래][at the bottom]에 댓글을 남기실 수도 있습니다. 이 포스트와 관련된 모든 소스 코드는 저장소의 [`post-03 브랜치`][post branch]에서 확인하실 수 있습니다.
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-03
<!-- toc -->
## VGA 텍스트 버퍼
VGA 텍스트 모드에서 화면에 문자를 출력하려면 VGA 하드웨어의 텍스트 버퍼에 해당 문자를 저장해야 합니다. VGA 텍스트 버퍼는 보통 25행 80열 크기의 2차원 배열이며, 해당 버퍼에 저장된 값들은 즉시 화면에 렌더링 됩니다. 배열의 각 원소는 화면에 출력될 문자를 아래의 형식으로 표현합니다:
비트 | 값
------ | ----------------
0-7 | ASCII 코드
8-11 | 전경색
12-14 | 배경색
15 | 깜빡임 여부
첫 바이트는 [ASCII 인코딩][ASCII encoding]으로 출력될 문자를 나타냅니다. 엄밀히 따지자면 ASCII 인코딩이 아닌, 해당 인코딩에 문자들을 추가하고 살짝 변형한 [_code page 437_] 이라는 인코딩을 이용합니다. 설명을 간소화하기 위해 이하 본문에서는 그냥 ASCII 문자로 지칭하겠습니다.
[ASCII encoding]: https://en.wikipedia.org/wiki/ASCII
[_code page 437_]: https://en.wikipedia.org/wiki/Code_page_437
두 번째 바이트는 표현하는 문자가 어떻게 표시될 것인지를 정의합니다. 두 번째 바이트의 첫 4비트는 전경색을 나타내고, 그 다음 3비트는 배경색을 나타내며, 마지막 비트는 해당 문자가 화면에서 깜빡이도록 할지 결정합니다. 아래의 색상들을 이용할 수 있습니다:
숫자 값 | 색상 | 색상 + 밝기 조정 비트 | 밝기 조정 후 최종 색상
------ | ---------- | ------------------- | -------------
0x0 | Black | 0x8 | Dark Gray
0x1 | Blue | 0x9 | Light Blue
0x2 | Green | 0xa | Light Green
0x3 | Cyan | 0xb | Light Cyan
0x4 | Red | 0xc | Light Red
0x5 | Magenta | 0xd | Pink
0x6 | Brown | 0xe | Yellow
0x7 | Light Gray | 0xf | White
두 번째 바이트의 네 번째 비트 (_밝기 조정 비트_)를 통해 파란색을 하늘색으로 조정하는 등 색의 밝기를 변경할 수 있습니다. 배경색을 지정하는 3비트 이후의 마지막 비트는 깜빡임 여부를 지정합니다.
[메모리 맵 입출력 (memory-mapped I/O)][memory-mapped I/O]으로 메모리 주소 `0xb8000`을 통해 VGA 텍스트 버퍼에 접근할 수 있습니다. 해당 주소에 읽기/쓰기 작업을 하면 RAM 대신 VGA 텍스트 버퍼에 직접 읽기/쓰기가 적용됩니다.
[memory-mapped I/O]: https://en.wikipedia.org/wiki/Memory-mapped_I/O
메모리 맵 입출력 적용 대상 하드웨어가 일부 RAM 작업을 지원하지 않을 가능성을 염두해야 합니다. 예를 들어, 바이트 단위 읽기만 지원하는 장치로부터 메모리 맵 입출력을 통해 `u64`를 읽어들일 경우 쓰레기 값이 반환될 수도 있습니다. 다행히 텍스트 버퍼는 [일반적인 읽기/쓰기 작업들을 모두 지원하기에][supports normal reads and writes] 읽기/쓰기를 위한 특수 처리가 필요하지 않습니다.
[supports normal reads and writes]: https://web.stanford.edu/class/cs140/projects/pintos/specs/freevga/vga/vgamem.htm#manip
## Rust 모듈
이제 VGA 버퍼가 어떻게 작동하는지 알았으니, 버퍼를 이용해 출력하는 것을 담당할 Rust 모듈을 만들어봅시다:
```rust
//in src/main.rs
mod vga_buffer;
```
새로운 모듈 `vga_buffer`를 위해 파일 `src/vga_buffer.rs`을 만듭니다. 이후 나타나는 모든 코드는 이 모듈에 들어갈 내용입니다 (별도의 지시 사항이 붙는 경우 제외).
### 색상
우선 enum을 이용하여 사용 가능한 여러 색상들을 표현합니다:
```rust
// in src/vga_buffer.rs
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
```
각 색상마다 고유 숫자 값을 배정할 수 있도록 우리는 [C언어와 같은 enum][C-like enum]을 사용합니다. `repr(u8)` 속성 때문에 enum의 각 분류 값은 `u8` 타입으로 저장됩니다. 사실 저장 공간은 4 비트만으로도 충분하지만, Rust에는 `u4` 타입이 없습니다.
[C-like enum]: https://doc.rust-lang.org/rust-by-example/custom_types/enum/c_like.html
사용되지 않는 enum 분류 값이 있을 때마다 컴파일러는 불필요한 코드가 있다는 경고 메시지를 출력합니다. 하지만 위처럼 `#[allow(dead_code)]` 속성을 적용하면 `Color` enum에 대해서는 컴파일러가 해당 경고 메시지를 출력하지 않습니다.
`Color` 타입에 [`Copy`], [`Clone`], [`Debug`], [`PartialEq`] 그리고 [`Eq`] 트레이트들을 [구현 (derive)][deriving] 함으로써 `Color` 타입이 [copy semantics] 를 따르도록 하고 또한 `Color` 타입 변수를 출력하거나 두 `Color` 타입 변수를 서로 비교할 수 있도록 합니다.
[deriving]: https://doc.rust-lang.org/rust-by-example/trait/derive.html
[`Copy`]: https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html
[`Clone`]: https://doc.rust-lang.org/nightly/core/clone/trait.Clone.html
[`Debug`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Debug.html
[`PartialEq`]: https://doc.rust-lang.org/nightly/core/cmp/trait.PartialEq.html
[`Eq`]: https://doc.rust-lang.org/nightly/core/cmp/trait.Eq.html
[copy semantics]: https://doc.rust-lang.org/1.30.0/book/first-edition/ownership.html#copy-types
전경색과 배경색을 모두 표현할 수 있는 색상 코드를 표현하기 위해 `u8` 타입을 감싸는 [newtype]을 선언합니다:
[newtype]: https://doc.rust-lang.org/rust-by-example/generics/new_types.html
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
```
`ColorCode` 구조체는 전경색 및 배경색을 모두 표현하는 색상 바이트 전체의 정보를 지닙니다. 이전처럼 `Copy``Debug` 트레이트를 구현 (derive) 해줍니다. `ColorCode` 구조체가 메모리 상에서 `u8` 타입과 같은 저장 형태를 가지도록 [`repr(transparent)`] 속성을 적용합니다.
[`repr(transparent)`]: https://doc.rust-lang.org/nomicon/other-reprs.html#reprtransparent
### 텍스트 버퍼
스크린 상의 문자 및 텍스트 버퍼를 표현하는 구조체들을 아래와 같이 추가합니다:
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
#[repr(transparent)]
struct Buffer {
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
Rust에서는 구조체 정의 코드에서의 필드 정렬 순서와 메모리 상에서 구조체의 각 필드가 저장되는 순서가 동일하지 않을 수 있습니다. 구조체의 각 필드 정렬 순서가 컴파일 중에 바뀌지 않도록 하려면 [`repr(C)`] 속성이 필요합니다. 이 속성을 사용하면 C언어의 구조체처럼 컴파일러가 구조체 내 각 필드의 정렬 순서를 임의로 조정할 수 없게 되기에, 우리는 메모리 상에서 구조체의 각 필드가 어떤 순서로 저장되는지 확신할 수 있습니다. 또한 `Buffer` 구조체에 [`repr(transparent)`] 속성을 적용하여 메모리 상에서 해당 구조체가 저장되는 형태가 `chars` 필드의 저장 형태와 동일하도록 해줍니다.
[`repr(C)`]: https://doc.rust-lang.org/nightly/nomicon/other-reprs.html#reprc
이제 아래와 같은 Writer 타입을 만들어 실제로 화면에 출력하는 데에 이용할 것입니다:
```rust
// in src/vga_buffer.rs
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
```
Writer는 언제나 가장 마지막 행에 값을 작성할 것이며, 작성 중인 행이 꽉 차거나 개행문자를 입력받은 경우에는 작성 중이던 행을 마치고 새로운 행으로 넘어갈 것입니다. 전경색 및 배경색은 `color_code`를 통해 표현되고 `buffer`에 VGA 버퍼에 대한 레퍼런스를 저장합니다. `buffer`에 대한 레퍼런스가 유효한 기간을 컴파일러에게 알리기 위해서 [명시적인 lifetime][explicit lifetime]이 필요합니다. [`'static`] lifetime 표기는 VGA 버퍼에 대한 레퍼런스가 프로그램 실행 시간 내내 유효하다는 것을 명시합니다.
[explicit lifetime]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-annotation-syntax
[`'static`]: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime
### 출력하기
이제 `Writer`를 이용하여 VGA 버퍼에 저장된 문자들을 변경할 수 있게 되었습니다. 우선 아래와 같이 하나의 ASCII 바이트를 출력하는 함수를 만듭니다:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
self.buffer.chars[row][col] = ScreenChar {
ascii_character: byte,
color_code,
};
self.column_position += 1;
}
}
}
fn new_line(&mut self) {/* TODO */}
}
```
주어진 바이트 값이 [개행 문자][newline] `\n`일 경우, Writer는 아무것도 출력하지 않고 대신 `new_line` 함수 (아래에서 함께 구현할 예정)를 호출합니다. 다른 바이트 값들은 match문의 두 번째 패턴에 매치되어 화면에 출력됩니다.
[newline]: https://en.wikipedia.org/wiki/Newline
바이트를 출력할 때, Writer는 현재 행이 가득 찼는지 확인합니다. 현재 행이 가득 찬 경우, 개행을 위해 `new_line` 함수를 먼저 호출해야 합니다. 그 후 버퍼에서의 현재 위치에 새로운 `ScreenChar`를 저장합니다. 마지막으로 현재 열 위치 값을 한 칸 올립니다.
위에서 구현한 함수로 문자열의 각 문자를 하나씩 출력함으로써 문자열 전체를 출력할 수도 있습니다:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
// 출력 가능한 ASCII 바이트 혹은 개행 문자
0x20..=0x7e | b'\n' => self.write_byte(byte),
// ASCII 코드 범위 밖의 값
_ => self.write_byte(0xfe),
}
}
}
}
```
VGA 텍스트 버퍼는 ASCII 문자 및 [코드 페이지 437][code page 437] 인코딩의 문자들만 지원합니다. Rust의 문자열은 기본 인코딩이 [UTF-8]이기에 VGA 텍스트 버퍼가 지원하지 않는 바이트들을 포함할 수 있습니다. 그렇기에 위 함수에서 `match`문을 통해 VGA 버퍼를 통해 출력 가능한 문자 (개행 문자 및 스페이스 문자와 `~` 문자 사이의 모든 문자)와 그렇지 않은 문자를 구분하여 처리합니다. 출력 불가능한 문자의 경우, VGA 하드웨어에서 16진수 코드 `0xfe`를 가지는 문자 (`■`)을 출력합니다.
[code page 437]: https://en.wikipedia.org/wiki/Code_page_437
[UTF-8]: https://www.fileformat.info/info/unicode/utf8.htm
#### 테스트 해봅시다!
간단한 함수를 하나 만들어 화면에 문자들을 출력해봅시다:
```rust
// in src/vga_buffer.rs
pub fn print_something() {
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello ");
writer.write_string("Wörld!");
}
```
우선 메모리 주소 `0xb8000`을 가리키는 새로운 Writer 인스턴스를 생성합니다. 이를 구현한 코드가 다소 난해하게 느껴질 수 있으니 단계별로 나누어 설명드리겠습니다: 먼저 정수 `0xb8000`을 읽기/쓰기 모두 가능한 (mutable) [포인터][raw pointer]로 타입 변환합니다. 그 후 `*` 연산자를 통해 이 포인터를 역참조 (dereference) 하고 `&mut`를 통해 즉시 borrow 함으로써 해당 주소에 저장된 값을 변경할 수 있는 레퍼런스 (mutable reference)를 만듭니다. 여기서 Rust 컴파일러는 포인터의 유효성 및 안전성을 보증할 수 없기에, [`unsafe` 블록][`unsafe` block]을 사용해야만 포인터를 레퍼런스로 변환할 수 있습니다.
[raw pointer]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer
[`unsafe` block]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
그 다음 Writer 인스턴스에 바이트 `b'H'`를 적습니다. 접두사 `b`는 ASCII 문자를 나타내는 [바이트 상수 (literal)][byte literal] 를 생성합니다. 문자열 `"ello "``"Wörld!"`를 적음으로써 `write_string` 함수 및 출력 불가능한 문자에 대한 특수 처리가 잘 구현되었는지 테스트 해봅니다. 화면에 메시지가 출력되는지 확인하기 위해 `print_something` 함수를 `_start` 함수에서 호출합니다:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
vga_buffer::print_something();
loop {}
}
```
프로젝트를 실행하면 `Hello W■■rld!` 라는 메시지가 화면 왼쪽 _아래_ 구석에 노란 텍스트로 출력됩니다:
[byte literal]: https://doc.rust-lang.org/reference/tokens.html#byte-literals
![QEMU output with a yellow `Hello W■■rld!` in the lower left corner](vga-hello.png)
문자 `ö` 대신 두 개의 `■` 문자가 출력되었습니다. 문자 `ö`는 [UTF-8] 인코딩에서 두 바이트로 표현되는데, 각각의 바이트가 출력 가능한 ASCII 문자 범위에 있지 않기 때문입니다. 이는 사실 UTF-8 인코딩의 핵심 특징으로, 두 바이트 이상으로 표현되는 문자들의 각 바이트는 유효한 ASCII 값을 가질 수 없습니다.
### Volatile
위에서 화면에 메시지가 출력되는 것을 확인했습니다. 하지만 미래의 Rust 컴파일러가 더 공격적으로 프로그램 최적화를 하게 된다면 메시지가 출력되지 않을 수 있습니다.
여기서 주목해야 할 것은 우리가 `Buffer`에 데이터를 쓰기만 할 뿐 읽지는 않는다는 점입니다. 컴파일러는 우리가 일반 RAM 메모리가 아닌 VGA 버퍼 메모리에 접근한다는 사실을 알지 못하며, 해당 버퍼에 쓰인 값이 화면에 출력되는 현상 (외부에서 관찰 가능한 상태 변화)에 대해서도 이해하지 못합니다. 그렇기에 컴파일러가 VGA 버퍼에 대한 쓰기 작업이 불필요하다고 판단하여 프로그램 최적화 중에 해당 작업들을 삭제할 수도 있습니다. 이를 방지하려면 VGA 버퍼에 대한 쓰기 작업이 _[volatile]_ 하다고 명시함으로써 해당 쓰기 작업이 관찰 가능한 상태 변화 (side effect)를 일으킨다는 것을 컴파일러에게 알려야 합니다.
[volatile]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
VGA 버퍼에 volatile한 방식으로 데이터를 쓰기 위해 우리는 [volatile][volatile crate] 크레이트를 사용합니다. 이 _크레이트_ (패키지 형태의 Rust 라이브러리) 는 `Volatile` 이라는 포장 타입 (wrapper type)과 함께 `read``write` 함수들을 제공합니다. 이 함수들은 내부적으로 Rust 코어 라이브러리의 [read_volatile] 및 [write_volatile] 함수들을 사용함으로써 읽기/쓰기 작업이 프로그램 최적화 중에 제거되지 않게 합니다.
[volatile crate]: https://docs.rs/volatile
[read_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.read_volatile.html
[write_volatile]: https://doc.rust-lang.org/nightly/core/ptr/fn.write_volatile.html
`Cargo.toml``dependencies` 섹션에 `volatile` 크레이트를 추가합니다:
```toml
# in Cargo.toml
[dependencies]
volatile = "0.2.6"
```
`volatile` 크레이트의 `0.2.6` 버전을 사용하셔야 합니다. 그 이후 버전의 `volatile` 크레이트는 이 포스트의 코드와 호환되지 않습니다. `0.2.6`은 [semantic] 버전 넘버를 나타내는데, 자세한 내용은 cargo 문서의 [Specifying Dependencies] 챕터를 확인해주세요.
[semantic]: https://semver.org/
[Specifying Dependencies]: https://doc.crates.io/specifying-dependencies.html
이제 이 크레이트를 써서 VGA 버퍼에 대한 쓰기 작업이 volatile 하도록 만들 것입니다. `Buffer` 타입을 정의하는 코드를 아래처럼 수정해주세요:
```rust
// in src/vga_buffer.rs
use volatile::Volatile;
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
`ScreenChar` 대신 `Volatile<ScreenChar>`를 사용합니다. (`Volatile` 타입은 [제네릭 (generic)][generic] 타입이며 거의 모든 타입을 감쌀 수 있습니다). 이로써 해당 타입에 대해 실수로 “일반” 쓰기 작업을 하는 실수를 방지할 수 있게 되었습니다. 이제 쓰기 작업 구현 시 `write` 함수만을 이용해야 합니다.
[generic]: https://doc.rust-lang.org/book/ch10-01-syntax.html
`Writer::write_byte` 함수가 `write`함수를 사용하도록 아래처럼 변경합니다:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
...
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code,
});
...
}
}
}
...
}
```
일반 대입 연산자 `=` 대신에 `write` 함수를 사용하였기에, 컴파일러는 최적화 단계에 절대로 해당 쓰기 작업을 삭제하지 않을 것입니다.
### 서식 정렬 매크로
`Writer` 타입이 Rust의 서식 정렬 매크로 (formatting macro) 를 지원한다면 정수나 부동 소수점 값 등 다양한 타입의 값들을 편리하고 쉽게 출력할 수 있을 것입니다. `Writer`가 Rust의 서식 정렬 매크로를 지원하려면 [`core::fmt::Write`] 트레이트를 구현해야 합니다. 해당 트레이트를 구현하기 위해서는 `write_str` 함수만 구현하면 되는데, 이 함수는 우리가 위에서 구현한 `write_string` 함수와 거의 유사하나 반환 타입이 `fmt::Result` 타입인 함수입니다:
[`core::fmt::Write`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Write.html
```rust
// in src/vga_buffer.rs
use core::fmt;
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
```
반환 값 `Ok(())``()` 타입을 감싸는 `Result` 타입의 `Ok` 입니다.
이제 Rust에서 기본적으로 제공되는 서식 정렬 매크로 `write!`/`writeln!`을 사용할 수 있습니다:
```rust
// in src/vga_buffer.rs
pub fn print_something() {
use core::fmt::Write;
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello! ");
write!(writer, "The numbers are {} and {}", 42, 1.0/3.0).unwrap();
}
```
화면 맨 아래에 메시지 `Hello! The numbers are 42 and 0.3333333333333333`가 출력될 것입니다. `write!` 매크로는 `Result`를 반환하는데, `Result`가 사용되지 않았다는 오류가 출력되지 않도록 [`unwrap`] 함수를 호출합니다. 반환된 `Result``Err()`일 경우 프로그램이 패닉 (panic) 하겠지만, 우리가 작성한 코드는 VGA 버퍼에 대한 쓰기 후 언제나 `Ok()`를 반환하기에 패닉이 발생하지 않습니다.
[`unwrap`]: https://doc.rust-lang.org/core/result/enum.Result.html#method.unwrap
### 개행
현재 행이 꽉 찬 상태에서 입력받은 문자 및 개행 문자에 대해 우리는 아직 아무런 대응을 하지 않습니다. 이러한 경우 현재 행의 모든 문자들을 한 행씩 위로 올려 출력하고 (맨 위 행은 지우고) 비워진 현재 행의 맨 앞 칸에서부터 다시 시작해야 합니다. 아래의 `new_line` 함수를 통해 해당 작업을 구현합니다:
```rust
// in src/vga_buffer.rs
impl Writer {
fn new_line(&mut self) {
for row in 1..BUFFER_HEIGHT {
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col].read();
self.buffer.chars[row - 1][col].write(character);
}
}
self.clear_row(BUFFER_HEIGHT - 1);
self.column_position = 0;
}
fn clear_row(&mut self, row: usize) {/* TODO */}
}
```
화면에 출력된 각 문자들을 순회하며 전부 한 행씩 위로 올려 출력합니다. 범위를 나타내는 `..` 표기는 범위의 상한 값을 포함하지 않는다는 것을 주의해 주세요. 0번째 행은 화면 밖으로 사라질 행이기에 순회하지 않습니다.
아래의 `clear_row` 함수를 추가하여 개행 문자 처리 코드를 완성합니다:
```rust
// in src/vga_buffer.rs
impl Writer {
fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ',
color_code: self.color_code,
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col].write(blank);
}
}
}
```
이 함수는 한 행의 모든 문자를 스페이스 문자로 덮어쓰는 방식으로 한 행의 내용을 전부 지웁니다.
## 전역 접근 가능한 인터페이스
`Writer` 인스턴스를 이리저리 옮겨다닐 필요가 없도록 전역 접근 가능한 `Writer`를 제공하기 위해 정적 변수 `WRITER`를 만들어 봅시다:
```rust
// in src/vga_buffer.rs
pub static WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
```
컴파일 시 아래의 오류 메시지가 출력될 것입니다:
```
error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
--> src/vga_buffer.rs:7:17
|
7 | color_code: ColorCode::new(Color::Yellow, Color::Black),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0396]: raw pointers cannot be dereferenced in statics
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dereference of raw pointer in constant
error[E0017]: references in statics may only refer to immutable values
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
error[E0017]: references in statics may only refer to immutable values
--> src/vga_buffer.rs:8:13
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
```
여기서 오류가 왜 발생했는지 이해하려면 우선 알아야 할 것이 있습니다. 그것은 바로 일반 자동 변수들이 프로그램 실행 시간에 초기화 되는 반면에 정적 (static) 변수들은 컴파일 시간에 초기화된다는 점입니다. Rust 컴파일러의 "[const evaluator]" 컴포넌트가 정적 변수를 컴파일 시간에 초기화합니다. 아직 구현된 기능이 많지는 않지만, 해당 컴포넌트의 기능을 확장하는 작업이 진행 중입니다 (예시: “[Allow panicking in constants]” RFC).
[const evaluator]: https://rustc-dev-guide.rust-lang.org/const-eval.html
[Allow panicking in constants]: https://github.com/rust-lang/rfcs/pull/2345
`ColorCode::new`에 대한 오류는 [`const` 함수][`const` functions]를 이용해 쉽게 해결할 수 있습니다. 더 큰 문제는 바로 Rust의 const evaluator가 컴파일 시간에 raw pointer를 레퍼런스로 전환하지 못한다는 것입니다. 미래에는 이것이 가능해질 수도 있겠지만, 현재로서는 다른 해법을 찾아야 합니다.
[`const` functions]: https://doc.rust-lang.org/reference/const_eval.html#const-functions
### 정적 변수의 초기화 지연
Rust 개발을 하다 보면 const가 아닌 함수를 이용해 1회에 한해 정적 변수의 값을 설정해야 하는 상황이 자주 발생합니다. [lazy_static] 크레이트의 `lazy_static!` 매크로를 이용하면, 정적 변수의 값을 컴파일 시간에 결정하지 않고 초기화 시점을 해당 프로그램 실행 중 변수에 대한 접근이 처음 일어나는 시점까지 미룰 수 있습니다. 즉, 정적 변수 초기화가 프로그램 실행 시간에 진행되기에 초기 값을 계산할 때 const가 아닌 복잡한 함수들을 사용할 수 있습니다.
[lazy_static]: https://docs.rs/lazy_static/1.0.1/lazy_static/
프로젝트 의존 라이브러리로서 `lazy_static` 크레이트를 추가해줍니다:
```toml
# in Cargo.toml
[dependencies.lazy_static]
version = "1.0"
features = ["spin_no_std"]
```
우리는 러스트 표준 라이브러리를 링크하지 않기에 `spin_no_std` 기능이 필요합니다.
`lazy_static` 크레이트 덕분에 이제 오류 없이 `WRITER`를 정의할 수 있습니다:
```rust
// in src/vga_buffer.rs
use lazy_static::lazy_static;
lazy_static! {
pub static ref WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
}
```
현재 `WRITER`는 immutable (읽기 가능, 쓰기 불가능) 하여 실질적인 쓸모가 없습니다. 모든 쓰기 함수들은 첫 인자로 `&mut self`를 받기 때문에 `WRITER`로 어떤 쓰기 작업도 할 수가 없습니다. 이에 대한 해결책으로 [mutable static]은 어떨까요? 이 선택지를 고른다면 모든 읽기 및 쓰기 작업이 데이터 레이스 (data race) 및 기타 위험에 노출되기에 안전을 보장할 수 없게 됩니다. Rust에서 `static mut`는 웬만하면 사용하지 않도록 권장되며, 심지어 [Rust 언어에서 완전히 `static mut`를 제거하자는 제안][remove static mut]이 나오기도 했습니다. 이것 이외에도 대안이 있을까요? [내부 가변성 (interior mutability)][interior mutability]을 제공하는 [RefCell] 혹은 [UnsafeCell] 을 통해 immutable한 정적 변수를 만드는 것은 어떨까요? 이 타입들은 중요한 이유로 [Sync] 트레이트를 구현하지 않기에 정적 변수를 선언할 때에는 사용할 수 없습니다.
[mutable static]: https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
[remove static mut]: https://internals.rust-lang.org/t/pre-rfc-remove-static-mut/1437
[RefCell]: https://doc.rust-lang.org/book/ch15-05-interior-mutability.html#keeping-track-of-borrows-at-runtime-with-refcellt
[UnsafeCell]: https://doc.rust-lang.org/nightly/core/cell/struct.UnsafeCell.html
[interior mutability]: https://doc.rust-lang.org/book/ch15-05-interior-mutability.html
[Sync]: https://doc.rust-lang.org/nightly/core/marker/trait.Sync.html
### 스핀 락 (Spinlocks)
표준 라이브러리의 [Mutex]는 동기화된 내부 가변성 (interior mutability)을 제공합니다. Mutex는 접근하려는 리소스가 잠겼을 때 현재 스레드를 블로킹 (blocking) 하는 것으로 상호 배제 (mutual exclusion)를 구현합니다. 우리의 커널은 스레드 블로킹은 커녕 스레드의 개념조차 구현하지 않기에 [Mutex]를 사용할 수 없습니다. 그 대신 우리에게는 운영체제 기능이 필요 없는 원시적인 [스핀 락 (spinlock)][spinlock]이 있습니다. 스핀 락은 Mutex와 달리 스레드를 블로킹하지 않고, 리소스의 잠김이 풀릴 때까지 반복문에서 계속 리소스 취득을 시도하면서 CPU 시간을 소모합니다.
[Mutex]: https://doc.rust-lang.org/nightly/std/sync/struct.Mutex.html
[spinlock]: https://en.wikipedia.org/wiki/Spinlock
스핀 락을 사용하기 위해 [spin 크레이트][spin crate] 를 의존 크레이트 목록에 추가합니다:
[spin crate]: https://crates.io/crates/spin
```toml
# in Cargo.toml
[dependencies]
spin = "0.5.2"
```
이제 스핀 락을 이용해 전역 변수 `WRITER`에 안전하게 [내부 가변성 (interior mutability)][interior mutability] 을 구현할 수 있습니다:
```rust
// in src/vga_buffer.rs
use spin::Mutex;
...
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
```
`print_something` 함수를 삭제하고 `_start` 함수에서 직접 메시지를 출력할 수 있습니다:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
use core::fmt::Write;
vga_buffer::WRITER.lock().write_str("Hello again").unwrap();
write!(vga_buffer::WRITER.lock(), ", some numbers: {} {}", 42, 1.337).unwrap();
loop {}
}
```
`fmt::Write` 트레이트를 가져와야 이 트레이트가 제공하는 함수들을 사용할 수 있습니다.
### 메모리 안전성
우리가 작성한 코드에는 unsafe 블록이 단 하나 존재합니다. 이 unsafe 블록은 주소 `0xb8000`을 가리키는 레퍼런스 `Buffer`를 초기화 하는 로직을 담기 위해 필요합니다. `Buffer`에 대한 초기화 이외 모든 작업들은 안전합니다 (메모리 안전성 측면에서). Rust는 배열의 원소에 접근하는 코드에는 인덱스 값과 배열의 길이를 비교하는 로직을 자동으로 삽입하기에, 버퍼의 정해진 공간 밖에 실수로 데이터를 쓰는 것은 불가능합니다. 타입 시스템에서 요구하는 조건들을 코드에 알맞게 구현함으로써 외부 사용자에게 안전한 인터페이스를 제공할 수 있게 되었습니다.
### println 매크로
전역 변수 `Writer`도 갖추었으니 이제 프로젝트 내 어디서든 사용할 수 있는 `println` 매크로를 추가할 수 있습니다. Rust의 [매크로 문법][macro syntax]은 다소 난해하기에, 우리에게 필요한 매크로를 밑바닥부터 작성하지는 않을 것입니다. 그 대신 표준 라이브러리의 [`println!` 매크로][`println!` macro] 구현 코드를 참조할 것입니다:
[macro syntax]: https://doc.rust-lang.org/nightly/book/ch19-06-macros.html#declarative-macros-with-macro_rules-for-general-metaprogramming
[`println!` macro]: https://doc.rust-lang.org/nightly/std/macro.println!.html
```rust
#[macro_export]
macro_rules! println {
() => (print!("\n"));
($($arg:tt)*) => (print!("{}\n", format_args!($($arg)*)));
}
```
매크로는 `match`문의 여러 패턴들을 선언하듯 한 개 이상의 규칙을 통해 정의됩니다. `println` 매크로는 두 개의 규칙을 가집니다: 첫 번째 규칙은 매크로에 아무 인자도 전달되지 않았을 때 (예: `println!()`)에 적용되어 개행 문자를 출력하는 `print!("\n")` 코드를 생성합니다. 두 번째 규칙은 매크로에 여러 인자들이 주어졌을 때 적용됩니다 (예: `println!("Hello")` 혹은 `println!("Number: {}", 4)`). 두 번째 규칙은 주어진 인자들을 그대로 `print!` 매크로에 전달하고 인자 문자열 끝에 개행 문자를 추가한 코드를 생성합니다.
`#[macro_export]` 속성이 적용된 매크로는 외부 크레이트 및 현재 크레이트 내 어디서든 사용 가능해집니다 (기본적으로는 매크로가 정의된 모듈 내에서만 그 매크로를 쓸 수 있습니다). 또한 이 속성이 적용된 매크로는 크레이트의 최고 상위 네임스페이스에 배치되기에, 매크로를 쓰기 위해 가져올 때 `use std::println` 대신 `use std::macros::println`을 적어야 합니다.
[`print!` 매크로][`print!` macro]는 아래와 같이 정의되어 있습니다:
[`print!` macro]: https://doc.rust-lang.org/nightly/std/macro.print!.html
```rust
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::io::_print(format_args!($($arg)*)));
}
```
이 매크로는 `io` 모듈의 [`print` 함수][`_print` function]를 호출하는 코드로 변환됩니다. [변수 `$crate`][`$crate` variable]가 `std`로 변환되기에 다른 크레이트에서도 이 매크로를 사용할 수 있습니다.
[`format_args` 매크로][`format_args` macro]는 주어진 인자들로부터 [fmt::Arguments] 타입 오브젝트를 만들고, 이 오브젝트가 `_print` 함수에 전달됩니다. 표준 라이브러리의 [`_print` 함수][`_print` function]는 `print_to` 함수를 호출합니다. `print_to` 함수는 다양한 `Stdout` (표준 출력) 장치들을 모두 지원하기에 구현이 제법 복잡합니다. 우리는 VGA 버퍼에 출력하는 것만을 목표로 하기에 굳이 `print_to` 함수의 복잡한 구현을 가져올 필요가 없습니다.
[`_print` function]: https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698
[`$crate` variable]: https://doc.rust-lang.org/1.30.0/book/first-edition/macros.html#the-variable-crate
[`format_args` macro]: https://doc.rust-lang.org/nightly/std/macro.format_args.html
[fmt::Arguments]: https://doc.rust-lang.org/nightly/core/fmt/struct.Arguments.html
VGA 버퍼에 메시지를 출력하기 위해 `println!``print!` 매크로 구현 코드를 복사해 온 뒤 우리가 직접 정의한 `_print` 함수를 사용하도록 변경해줍니다:
```rust
// in src/vga_buffer.rs
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
기존 `println` 구현에서 `print!` 매크로를 호출하는 코드에 우리는 `$crate` 접두어를 추가했습니다.
이로써 `println` 매크로만 사용하고 싶은 경우에 `print` 매크로를 별도로 import 하지 않아도 됩니다.
표준 라이브러리의 구현과 마찬가지로, 두 매크로에 `#[macro_export]` 속성을 추가하여 크레이트 어디에서나 사용할 수 있도록 합니다. 이 속성이 추가된 두 매크로는 크레이트의 최고 상위 네임스페이스에 배정되기에, `use crate::vga_buffer::println` 대신 `use crate::println`을 사용하여 import 합니다.
`_print` 함수는 정적 변수 `WRITER`를 잠그고 `write_fmt` 함수를 호출합니다. 이 함수는 `Write` 트레이트를 통해 제공되기에, 이 트레이트를 import 해야 합니다. `write_fmt` 함수 호출 이후의 `unwrap()`으로 인해 출력이 실패할 경우 패닉이 발생합니다. 하지만 `write_str` 함수가 언제나 `Ok`를 반환하기에 패닉이 일어날 일은 없습니다.
우리의 매크로들이 모듈 밖에서 `_print` 함수를 호출할 수 있으려면 이 함수를 public 함수로 설정해야 합니다. public 함수이지만 구체적인 구현 방식은 드러나지 않도록 [`doc(hidden)` 속성][`doc(hidden)` attribute]을 추가하여 이 함수가 프로젝트 문서에 노출되지 않게 합니다.
[`doc(hidden)` attribute]: https://doc.rust-lang.org/nightly/rustdoc/write-documentation/the-doc-attribute.html#hidden
### `println`을 이용해 "Hello World" 출력하기
이제 `_start` 함수에서 `println`을 사용할 수 있습니다:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() {
println!("Hello World{}", "!");
loop {}
}
```
`println!` 매크로가 이미 루트 네임스페이스에 배정되었기에, main 함수에서 사용하기 위해 다시 매크로를 import 할 필요가 없습니다.
예상한 대로, 화면에 _“Hello World!”_ 가 출력된 것을 확인할 수 있습니다:
![QEMU printing “Hello World!”](vga-hello-world.png)
### 패닉 메시지 출력하기
`println` 매크로를 이용하여 `panic` 함수에서도 패닉 메시지 및 패닉이 발생한 코드 위치를 출력할 수 있게 되었습니다:
```rust
// in main.rs
/// This function is called on panic.
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
```
`_start` 함수에 `panic!("Some panic message")` 을 추가한 후 빌드 및 실행하면 아래와 같은 출력 내용을 확인할 수 있을 것입니다:
![QEMU printing “panicked at 'Some panic message', src/main.rs:28:5](vga-panic.png)
출력 내용을 통해 패닉 발생 여부, 패닉 메시지 그리고 패닉이 일어난 코드 위치까지도 알 수 있습니다.
## 정리
이 포스트에서는 VGA 텍스트 버퍼의 구조 및 메모리 주소 `0xb8000`로의 메모리 매핑을 통해 어떻게 VGA 텍스트 버퍼에 쓰기 작업을 할 수 있는지에 대해 다뤘습니다. 또한 메모리 매핑 된 버퍼에 대한 쓰기 기능 (안전하지 않은 작업)을 안전하고 편리한 인터페이스로 제공하는 Rust 모듈을 작성했습니다.
또한 cargo를 이용하여 의존 크레이트를 추가하는 것이 얼마나 쉬운지 직접 확인해볼 수 있었습니다.
이번 포스트에서 추가한 의존 크레이트 `lazy_static``spin`은 운영체제 개발에 매우 유용하기에 이후 포스트에서도 자주 사용할 것입니다.
## 다음 단계는 무엇일까요?
다음 포스트에서는 Rust의 자체 유닛 테스트 프레임워크를 설정하는 법에 대해 설명할 것입니다. 그리고 나서 이번 포스트에서 작성한 VGA 버퍼 모듈을 위한 기본적인 유닛 테스트들을 작성할 것입니다.

View File

@@ -1,652 +0,0 @@
+++
title = "VGA 字符模式"
weight = 3
path = "zh-CN/vga-text-mode"
date = 2018-02-26
[extra]
# Please update this when updating the translation
translation_based_on_commit = "bd6fbcb1c36705b2c474d7fcee387bfea1210851"
# GitHub usernames of the people that translated this post
translators = ["luojia65", "Rustin-Liu"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["liuyuran"]
+++
**VGA 字符模式**[VGA text mode])是打印字符到屏幕的一种简单方式。在这篇文章中,为了包装这个模式为一个安全而简单的接口,我们将包装 unsafe 代码到独立的模块。我们还将实现对 Rust 语言**格式化宏**[formatting macros])的支持。
[VGA text mode]: https://en.wikipedia.org/wiki/VGA-compatible_text_mode
[formatting macros]: https://doc.rust-lang.org/std/fmt/#related-macros
<!-- more -->
此博客在 [GitHub] 上公开开发. 如果您有任何问题或疑问,请在此处打开一个 issue。 您也可以在[底部][at the bottom]发表评论. 这篇文章的完整源代码可以在 [`post-03`] [post branch] 分支中找到。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-03
<!-- toc -->
## VGA 字符缓冲区
为了在 VGA 字符模式中向屏幕打印字符,我们必须将它写入硬件提供的 **VGA 字符缓冲区**VGA text buffer。通常状况下VGA 字符缓冲区是一个 25 行、80 列的二维数组,它的内容将被实时渲染到屏幕。这个数组的元素被称作**字符单元**character cell它使用下面的格式描述一个屏幕上的字符
| Bit(s) | Value |
| ------ | ---------------- |
| 0-7 | ASCII code point |
| 8-11 | Foreground color |
| 12-14 | Background color |
| 15 | Blink |
第一个字节表示了应当输出的 [ASCII 编码][ASCII encoding],更加准确的说,类似于 [437 字符编码表][_code page 437_] 中字符对应的编码,但又有细微的不同。 这里为了简化表达我们在文章里将其简称为ASCII字符。
[ASCII encoding]: https://en.wikipedia.org/wiki/ASCII
[_code page 437_]: https://en.wikipedia.org/wiki/Code_page_437
第二个字节则定义了字符的显示方式,前四个比特定义了前景色,中间三个比特定义了背景色,最后一个比特则定义了该字符是否应该闪烁,以下是可用的颜色列表:
| Number | Color | Number + Bright Bit | Bright Color |
| ------ | ---------- | ------------------- | ------------ |
| 0x0 | Black | 0x8 | Dark Gray |
| 0x1 | Blue | 0x9 | Light Blue |
| 0x2 | Green | 0xa | Light Green |
| 0x3 | Cyan | 0xb | Light Cyan |
| 0x4 | Red | 0xc | Light Red |
| 0x5 | Magenta | 0xd | Pink |
| 0x6 | Brown | 0xe | Yellow |
| 0x7 | Light Gray | 0xf | White |
每个颜色的第四位称为**加亮位**bright bit比如blue加亮后就变成了light blue但对于背景色这个比特会被用于标记是否闪烁。
要修改 VGA 字符缓冲区,我们可以通过**存储器映射输入输出**[memory-mapped I/O](https://en.wikipedia.org/wiki/Memory-mapped_I/O))的方式,读取或写入地址 `0xb8000`;这意味着,我们可以像操作普通的内存区域一样操作这个地址。
需要注意的是,一些硬件虽然映射到存储器,但可能不会完全支持所有的内存操作:可能会有一些设备支持按 `u8` 字节读取,但在读取 `u64` 时返回无效的数据。幸运的是,字符缓冲区都[支持标准的读写操作](https://web.stanford.edu/class/cs140/projects/pintos/specs/freevga/vga/vgamem.htm#manip),所以我们不需要用特殊的标准对待它。
## 包装到 Rust 模块
既然我们已经知道 VGA 文字缓冲区如何工作,也是时候创建一个 Rust 模块来处理文字打印了。我们输入这样的代码:
```rust
//in src/main.rs
mod vga_buffer;
```
我们的模块暂时不需要添加子模块,所以我们将它创建为 `src/vga_buffer.rs` 文件。除非另有说明,本文中的代码都保存到这个文件中。
### 颜色
首先,我们使用 Rust 的**枚举**enum表示特定的颜色
```rust
// in src/vga_buffer.rs
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
Black = 0,
Blue = 1,
Green = 2,
Cyan = 3,
Red = 4,
Magenta = 5,
Brown = 6,
LightGray = 7,
DarkGray = 8,
LightBlue = 9,
LightGreen = 10,
LightCyan = 11,
LightRed = 12,
Pink = 13,
Yellow = 14,
White = 15,
}
```
我们使用**类似于 C 语言的枚举**C-like enum为每个颜色明确指定一个数字。在这里每个用 `repr(u8)` 注记标注的枚举类型,都会以一个 `u8` 的形式存储——事实上 4 个二进制位就足够了,但 Rust 语言并不提供 `u4` 类型。
通常来说,编译器会对每个未使用的变量发出**警告**warning使用 `#[allow(dead_code)]`,我们可以对 `Color` 枚举类型禁用这个警告。
我们还**生成**[derive])了 [`Copy`](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html)、[`Clone`](https://doc.rust-lang.org/nightly/core/clone/trait.Clone.html)、[`Debug`](https://doc.rust-lang.org/nightly/core/fmt/trait.Debug.html)、[`PartialEq`](https://doc.rust-lang.org/nightly/core/cmp/trait.PartialEq.html) 和 [`Eq`](https://doc.rust-lang.org/nightly/core/cmp/trait.Eq.html) 这几个 trait这让我们的类型遵循**复制语义**[copy semantics]),也让它可以被比较、被调试和打印。
[derive]: https://doc.rust-lang.org/rust-by-example/trait/derive.html
[copy semantics]: https://doc.rust-lang.org/1.30.0/book/first-edition/ownership.html#copy-types
为了描述包含前景色和背景色的、完整的**颜色代码**color code我们基于 `u8` 创建一个新类型:
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);
impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
}
}
```
这里,`ColorCode` 类型包装了一个完整的颜色代码字节,它包含前景色和背景色信息。和 `Color` 类型类似,我们为它生成 `Copy``Debug` 等一系列 trait。为了确保 `ColorCode``u8` 有完全相同的内存布局,我们添加 [repr(transparent) 标记](https://doc.rust-lang.org/nomicon/other-reprs.html#reprtransparent)。
### 字符缓冲区
现在,我们可以添加更多的结构体,来描述屏幕上的字符和整个字符缓冲区:
```rust
// in src/vga_buffer.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;
#[repr(transparent)]
struct Buffer {
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
在内存布局层面Rust 并不保证按顺序布局成员变量。因此,我们需要使用 `#[repr(C)]` 标记结构体;这将按 C 语言约定的顺序布局它的成员变量,让我们能正确地映射内存片段。对 `Buffer` 类型,我们再次使用 `repr(transparent)`,来确保类型和它的单个成员有相同的内存布局。
为了输出字符到屏幕,我们来创建一个 `Writer` 类型:
```rust
// in src/vga_buffer.rs
pub struct Writer {
column_position: usize,
color_code: ColorCode,
buffer: &'static mut Buffer,
}
```
我们将让这个 `Writer` 类型将字符写入屏幕的最后一行,并在一行写满或接收到换行符 `\n` 的时候,将所有的字符向上位移一行。`column_position` 变量将跟踪光标在最后一行的位置。当前字符的前景和背景色将由 `color_code` 变量指定;另外,我们存入一个 VGA 字符缓冲区的可变借用到`buffer`变量中。需要注意的是,这里我们对借用使用**显式生命周期**[explicit lifetime](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-annotation-syntax)),告诉编译器这个借用在何时有效:我们使用** `'static` 生命周期 **['static lifetime](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime)),意味着这个借用应该在整个程序的运行期间有效;这对一个全局有效的 VGA 字符缓冲区来说,是非常合理的。
### 打印字符
现在我们可以使用 `Writer` 类型来更改缓冲区内的字符了。首先,为了写入一个 ASCII 码字节,我们创建这样的函数:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line();
}
let row = BUFFER_HEIGHT - 1;
let col = self.column_position;
let color_code = self.color_code;
self.buffer.chars[row][col] = ScreenChar {
ascii_character: byte,
color_code,
};
self.column_position += 1;
}
}
}
fn new_line(&mut self) {/* TODO */}
}
```
如果这个字节是一个**换行符**[line feed](https://en.wikipedia.org/wiki/Newline))字节 `\n`,我们的 `Writer` 不应该打印新字符,相反,它将调用我们稍后会实现的 `new_line` 方法;其它的字节应该将在 `match` 语句的第二个分支中被打印到屏幕上。
当打印字节时,`Writer` 将检查当前行是否已满。如果已满,它将首先调用 `new_line` 方法来将这一行字向上提升,再将一个新的 `ScreenChar` 写入到缓冲区,最终将当前的光标位置前进一位。
要打印整个字符串,我们把它转换为字节并依次输出:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
// 可以是能打印的 ASCII 码字节,也可以是换行符
0x20..=0x7e | b'\n' => self.write_byte(byte),
// 不包含在上述范围之内的字节
_ => self.write_byte(0xfe),
}
}
}
}
```
VGA 字符缓冲区只支持 ASCII 码字节和**代码页 437**[Code page 437](https://en.wikipedia.org/wiki/Code_page_437)定义的字节。Rust 语言的字符串默认编码为 [UTF-8](https://www.fileformat.info/info/unicode/utf8.htm),也因此可能包含一些 VGA 字符缓冲区不支持的字节:我们使用 `match` 语句,来区别可打印的 ASCII 码或换行字节,和其它不可打印的字节。对每个不可打印的字节,我们打印一个 `■` 符号;这个符号在 VGA 硬件中被编码为十六进制的 `0xfe`
我们可以亲自试一试已经编写的代码。为了这样做,我们可以临时编写一个函数:
```rust
// in src/vga_buffer.rs
pub fn print_something() {
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello ");
writer.write_string("Wörld!");
}
```
这个函数首先创建一个指向 `0xb8000` 地址VGA缓冲区的 `Writer`。实现这一点,我们需要编写的代码可能看起来有点奇怪:首先,我们把整数 `0xb8000` 强制转换为一个可变的**裸指针**[raw pointer](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer));之后,通过运算符`*`,我们将这个裸指针解引用;最后,我们再通过 `&mut`,再次获得它的可变借用。这些转换需要 **`unsafe` 语句块**[unsafe block](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html)),因为编译器并不能保证这个裸指针是有效的。
然后它将字节 `b'H'` 写入缓冲区内. 前缀 `b` 创建了一个字节常量([byte literal](https://doc.rust-lang.org/reference/tokens.html#byte-literals)),表示单个 ASCII 码字符;通过尝试写入 `"ello "``"Wörld!"`,我们可以测试 `write_string` 方法和其后对无法打印字符的处理逻辑。为了观察输出,我们需要在 `_start` 函数中调用 `print_something` 方法:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
vga_buffer::print_something();
loop {}
}
```
编译运行后,黄色的 `Hello W■■rld!` 字符串将会被打印在屏幕的左下角:
![QEMU output with a yellow Hello W■■rld! in the lower left corner](https://os.phil-opp.com/vga-text-mode/vga-hello.png)
需要注意的是,`ö` 字符被打印为两个 `■` 字符。这是因为在 [UTF-8](https://www.fileformat.info/info/unicode/utf8.htm) 编码下,字符 `ö` 是由两个字节表述的——而这两个字节并不处在可打印的 ASCII 码字节范围之内。事实上,这是 UTF-8 编码的基本特点之一:**如果一个字符占用多个字节,那么每个组成它的独立字节都不是有效的 ASCII 码字节**the individual bytes of multi-byte values are never valid ASCII
### 易失操作
我们刚才看到,自己想要输出的信息被正确地打印到屏幕上。然而,未来 Rust 编译器更暴力的优化可能让这段代码不按预期工作。
产生问题的原因在于,我们只向 `Buffer` 写入,却不再从它读出数据。此时,编译器不知道我们事实上已经在操作 VGA 缓冲区内存,而不是在操作普通的 RAM——因此也不知道产生的**副效应**side effect即会有几个字符显示在屏幕上。这时编译器也许会认为这些写入操作都没有必要甚至会选择忽略这些操作所以为了避免这些并不正确的优化这些写入操作应当被指定为[易失操作](https://en.wikipedia.org/wiki/Volatile_(computer_programming))。这将告诉编译器,这些写入可能会产生副效应,不应该被优化掉。
为了在我们的 VGA 缓冲区中使用易失的写入操作,我们使用 [volatile](https://docs.rs/volatile) 库。这个**包**crate提供一个名为 `Volatile` 的**包装类型**wrapping type和它的 `read``write` 方法;这些方法包装了 `core::ptr` 内的 [read_volatile](https://doc.rust-lang.org/nightly/core/ptr/fn.read_volatile.html) 和 [write_volatile](https://doc.rust-lang.org/nightly/core/ptr/fn.write_volatile.html) 函数,从而保证读操作或写操作不会被编译器优化。
要添加 `volatile` 包为项目的**依赖项**dependency我们可以在 `Cargo.toml` 文件的 `dependencies` 中添加下面的代码:
```toml
# in Cargo.toml
[dependencies]
volatile = "0.2.6"
```
`0.2.6` 表示一个**语义版本号**[semantic version number](https://semver.org/)),在 cargo 文档的[《指定依赖项》章节](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html)可以找到与它相关的使用指南。
现在,我们使用它来完成 VGA 缓冲区的 volatile 写入操作。我们将 `Buffer` 类型的定义修改为下列代码:
```rust
// in src/vga_buffer.rs
use volatile::Volatile;
struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
```
在这里,我们不使用 `ScreenChar` ,而选择使用 `Volatile<ScreenChar>` ——在这里,`Volatile` 类型是一个**泛型**[generic](https://doc.rust-lang.org/book/ch10-01-syntax.html)),可以包装几乎所有的类型——这确保了我们不会通过普通的写入操作,意外地向它写入数据;我们转而使用提供的 `write` 方法。
这意味着,我们必须要修改我们的 `Writer::write_byte` 方法:
```rust
// in src/vga_buffer.rs
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
byte => {
...
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code: color_code,
});
...
}
}
}
...
}
```
正如代码所示,我们不再使用普通的 `=` 赋值,而使用了 `write` 方法:这能确保编译器不再优化这个写入操作。
### 格式化宏
支持 Rust 提供的**格式化宏**formatting macros也是一个很好的思路。通过这种途径我们可以轻松地打印不同类型的变量如整数或浮点数。为了支持它们我们需要实现 [`core::fmt::Write`](https://doc.rust-lang.org/nightly/core/fmt/trait.Write.html) trait要实现它唯一需要提供的方法是 `write_str`,它和我们先前编写的 `write_string` 方法差别不大,只是返回值类型变成了 `fmt::Result`
```rust
// in src/vga_buffer.rs
use core::fmt;
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s);
Ok(())
}
}
```
这里,`Ok(())` 属于 `Result` 枚举类型中的 `Ok`,包含一个值为 `()` 的变量。
现在我们就可以使用 Rust 内置的格式化宏 `write!``writeln!` 了:
```rust
// in src/vga_buffer.rs
pub fn print_something() {
use core::fmt::Write;
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
writer.write_byte(b'H');
writer.write_string("ello! ");
write!(writer, "The numbers are {} and {}", 42, 1.0/3.0).unwrap();
}
```
现在,你应该在屏幕下端看到一串 `Hello! The numbers are 42 and 0.3333333333333333``write!` 宏返回的 `Result` 类型必须被使用,所以我们调用它的 [`unwrap`](https://doc.rust-lang.org/core/result/enum.Result.html#method.unwrap) 方法,它将在错误发生时 panic。这里的情况下应该不会发生这样的问题因为写入 VGA 字符缓冲区并没有可能失败。
### 换行
在之前的代码中,我们忽略了换行符,因此没有处理超出一行字符的情况。当换行时,我们想要把每个字符向上移动一行——此时最顶上的一行将被删除——然后在最后一行的起始位置继续打印。要做到这一点,我们要为 `Writer` 实现一个新的 `new_line` 方法:
```rust
// in src/vga_buffer.rs
impl Writer {
fn new_line(&mut self) {
for row in 1..BUFFER_HEIGHT {
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col].read();
self.buffer.chars[row - 1][col].write(character);
}
}
self.clear_row(BUFFER_HEIGHT - 1);
self.column_position = 0;
}
fn clear_row(&mut self, row: usize) {/* TODO */}
}
```
我们遍历每个屏幕上的字符,把每个字符移动到它上方一行的相应位置。这里,`..` 符号是**区间标号**range notation的一种它表示左闭右开的区间因此不包含它的上界。在外层的枚举中我们从第 1 行开始,省略了对第 0 行的枚举过程——因为这一行应该被移出屏幕,即它将被下一行的字符覆写。
所以我们实现的 `clear_row` 方法代码如下:
```rust
// in src/vga_buffer.rs
impl Writer {
fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ',
color_code: self.color_code,
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col].write(blank);
}
}
}
```
通过向对应的缓冲区写入空格字符,这个方法能清空一整行的字符位置。
## 全局接口
编写其它模块时,我们希望无需随时拥有 `Writer` 实例,便能使用它的方法。我们尝试创建一个静态的 `WRITER` 变量:
```rust
// in src/vga_buffer.rs
pub static WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
```
我们尝试编译这些代码,却发生了下面的编译错误:
```
error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
--> src/vga_buffer.rs:7:17
|
7 | color_code: ColorCode::new(Color::Yellow, Color::Black),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0396]: raw pointers cannot be dereferenced in statics
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dereference of raw pointer in constant
error[E0017]: references in statics may only refer to immutable values
--> src/vga_buffer.rs:8:22
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
error[E0017]: references in statics may only refer to immutable values
--> src/vga_buffer.rs:8:13
|
8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values
```
为了明白现在发生了什么我们需要知道一点一般的变量在运行时初始化而静态变量在编译时初始化。Rust编译器规定了一个称为**常量求值器**[const evaluator](https://rustc-dev-guide.rust-lang.org/const-eval.html))的组件,它应该在编译时处理这样的初始化工作。虽然它目前的功能较为有限,但对它的扩展工作进展活跃,比如允许在常量中 panic 的[一篇 RFC 文档](https://github.com/rust-lang/rfcs/pull/2345)。
关于 `ColorCode::new` 的问题应该能使用**常函数**[`const` functions](https://doc.rust-lang.org/reference/const_eval.html#const-functions))解决,但常量求值器还存在不完善之处,它还不能在编译时直接转换裸指针到变量的引用——也许未来这段代码能够工作,但在那之前,我们需要寻找另外的解决方案。
### 延迟初始化
使用非常函数初始化静态变量是 Rust 程序员普遍遇到的问题。幸运的是,有一个叫做 [lazy_static](https://docs.rs/lazy_static/1.0.1/lazy_static/) 的包提供了一个很棒的解决方案:它提供了名为 `lazy_static!` 的宏,定义了一个**延迟初始化**lazily initialized的静态变量这个变量的值将在第一次使用时计算而非在编译时计算。这时变量的初始化过程将在运行时执行任意的初始化代码——无论简单或复杂——都是能够使用的。
现在,我们将 `lazy_static` 包导入到我们的项目:
```toml
# in Cargo.toml
[dependencies.lazy_static]
version = "1.0"
features = ["spin_no_std"]
```
在这里,由于程序不连接标准库,我们需要启用 `spin_no_std` 特性。
使用 `lazy_static` 我们就可以定义一个不出问题的 `WRITER` 变量:
```rust
// in src/vga_buffer.rs
use lazy_static::lazy_static;
lazy_static! {
pub static ref WRITER: Writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
}
```
然而,这个 `WRITER` 可能没有什么用途,因为它目前还是**不可变变量**immutable variable这意味着我们无法向它写入数据因为所有与写入数据相关的方法都需要实例的可变引用 `&mut self`。一种解决方案是使用**可变静态**[mutable static](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable)的变量但所有对它的读写操作都被规定为不安全的unsafe操作因为这很容易导致数据竞争或发生其它不好的事情——使用 `static mut` 极其不被赞成,甚至有一些提案认为[应该将它删除](https://internals.rust-lang.org/t/pre-rfc-remove-static-mut/1437)。也有其它的替代方案,比如可以尝试使用比如 [RefCell](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html#keeping-track-of-borrows-at-runtime-with-refcellt) 或甚至 [UnsafeCell](https://doc.rust-lang.org/nightly/core/cell/struct.UnsafeCell.html) 等类型提供的**内部可变性**[interior mutability](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html));但这些类型都被设计为非同步类型,即不满足 [Sync](https://doc.rust-lang.org/nightly/core/marker/trait.Sync.html) 约束,所以我们不能在静态变量中使用它们。
### spinlock
要定义同步的内部可变性,我们往往使用标准库提供的互斥锁类 [Mutex](https://doc.rust-lang.org/nightly/std/sync/struct.Mutex.html),它通过提供当资源被占用时将线程**阻塞**block的**互斥条件**mutual exclusion实现这一点但我们初步的内核代码还没有线程和阻塞的概念我们将不能使用这个类。不过我们还有一种较为基础的互斥锁实现方式——**自旋锁**[spinlock](https://en.wikipedia.org/wiki/Spinlock))。自旋锁并不会调用阻塞逻辑,而是在一个小的无限循环中反复尝试获得这个锁,也因此会一直占用 CPU 时间,直到互斥锁被它的占用者释放。
为了使用自旋互斥锁,我们添加 [spin包](https://crates.io/crates/spin) 到项目的依赖项列表:
```toml
# in Cargo.toml
[dependencies]
spin = "0.5.2"
```
现在,我们能够使用自旋的互斥锁,为我们的 `WRITER` 类实现安全的[内部可变性](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html)
```rust
// in src/vga_buffer.rs
use spin::Mutex;
...
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
```
现在我们可以删除 `print_something` 函数,尝试直接在 `_start` 函数中打印字符:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
use core::fmt::Write;
vga_buffer::WRITER.lock().write_str("Hello again").unwrap();
write!(vga_buffer::WRITER.lock(), ", some numbers: {} {}", 42, 1.337).unwrap();
loop {}
}
```
在这里,我们需要导入名为 `fmt::Write` 的 trait来使用实现它的类的相应方法。
### 安全性
经过上面的努力后,我们现在的代码只剩一个 unsafe 语句块,它用于创建一个指向 `0xb8000` 地址的 `Buffer` 类型引用在这步之后所有的操作都是安全的。Rust 将为每个数组访问检查边界,所以我们不会在不经意间越界到缓冲区之外。因此,我们把需要的条件编码到 Rust 的类型系统,这之后,我们为外界提供的接口就符合内存安全原则了。
### `println!` 宏
现在我们有了一个全局的 `Writer` 实例,我们就可以基于它实现 `println!`这样它就能被任意地方的代码使用了。Rust 提供的[宏定义语法](https://doc.rust-lang.org/nightly/book/ch19-06-macros.html#declarative-macros-with-macro_rules-for-general-metaprogramming)需要时间理解,所以我们将不从零开始编写这个宏。我们先看看标准库中 [`println!` 宏的实现源码](https://doc.rust-lang.org/nightly/std/macro.println!.html)
```rust
#[macro_export]
macro_rules! println {
() => (print!("\n"));
($($arg:tt)*) => (print!("{}\n", format_args!($($arg)*)));
}
```
宏是通过一个或多个**规则**rule定义的这就像 `match` 语句的多个分支。`println!` 宏有两个规则:第一个规则不要求传入参数——就比如 `println!()` ——它将被扩展为 `print!("\n")`,因此只会打印一个新行;第二个要求传入参数——好比 `println!("Rust 能够编写操作系统")``println!("我学习 Rust 已经{}年了", 3)`——它将使用 `print!` 宏扩展,传入它需求的所有参数,并在输出的字符串最后加入一个换行符 `\n`
这里,`#[macro_export]` 属性让整个包crate和基于它的包都能访问这个宏而不仅限于定义它的模块module。它还将把宏置于包的根模块crate root这意味着比如我们需要通过 `use std::println` 来导入这个宏,而不是通过 `std::macros::println`
[`print!` 宏](https://doc.rust-lang.org/nightly/std/macro.print!.html)是这样定义的:
```
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::io::_print(format_args!($($arg)*)));
}
```
这个宏将扩展为一个对 `io` 模块中 [`_print` 函数](https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698)的调用。[`$crate` 变量](https://doc.rust-lang.org/1.30.0/book/first-edition/macros.html#the-variable-crate)将在 `std` 包之外被解析为 `std` 包,保证整个宏在 `std` 包之外也可以使用。
[`format_args!` 宏](https://doc.rust-lang.org/nightly/std/macro.format_args.html)将传入的参数搭建为一个 [fmt::Arguments](https://doc.rust-lang.org/nightly/core/fmt/struct.Arguments.html) 类型,这个类型将被传入 `_print` 函数。`std` 包中的 [`_print` 函数](https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698)将调用复杂的私有函数 `print_to`,来处理对不同 `Stdout` 设备的支持。我们不需要编写这样的复杂函数,因为我们只需要打印到 VGA 字符缓冲区。
要打印到字符缓冲区,我们把 `println!``print!` 两个宏复制过来,但修改部分代码,让这些宏使用我们定义的 `_print` 函数:
```rust
// in src/vga_buffer.rs
#[macro_export]
macro_rules! print {
($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
我们首先修改了 `println!` 宏,在每个使用的 `print!` 宏前面添加了 `$crate` 变量。这样我们在只需要使用 `println!` 时,不必也编写代码导入 `print!` 宏。
就像标准库做的那样,我们为两个宏都添加了 `#[macro_export]` 属性,这样在包的其它地方也可以使用它们。需要注意的是,这将占用包的**根命名空间**root namespace所以我们不能通过 `use crate::vga_buffer::println` 来导入它们;我们应该使用 `use crate::println`
另外,`_print` 函数将占有静态变量 `WRITER` 的锁,并调用它的 `write_fmt` 方法。这个方法是从名为 `Write` 的 trait 中获得的,所以我们需要导入这个 trait。额外的 `unwrap()` 函数将在打印不成功的时候 panic但既然我们的 `write_str` 总是返回 `Ok`,这种情况不应该发生。
如果这个宏将能在模块外访问,它们也应当能访问 `_print` 函数因此这个函数必须是公有的public。然而考虑到这是一个私有的实现细节我们添加一个 [`doc(hidden)` 属性](https://doc.rust-lang.org/nightly/rustdoc/write-documentation/the-doc-attribute.html#hidden),防止它在生成的文档中出现。
### 使用 `println!` 的 Hello World
现在,我们可以在 `_start` 里使用 `println!` 了:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() {
println!("Hello World{}", "!");
loop {}
}
```
要注意的是,我们在入口函数中不需要导入这个宏——因为它已经被置于包的根命名空间了。
运行这段代码,和我们预料的一样,一个 *“Hello World!”* 字符串被打印到了屏幕上:
![QEMU printing “Hello World!”](https://os.phil-opp.com/vga-text-mode/vga-hello-world.png)
### 打印 panic 信息
既然我们已经有了 `println!` 宏,我们可以在 panic 处理函数中,使用它打印 panic 信息和 panic 产生的位置:
```rust
// in main.rs
/// 这个函数将在 panic 发生时被调用
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
```
当我们在 `_start` 函数中插入一行 `panic!("Some panic message");` 后,我们得到了这样的输出:
![QEMU printing “panicked at 'Some panic message', src/main.rs:28:5](https://os.phil-opp.com/vga-text-mode/vga-panic.png)
所以,现在我们不仅能知道 panic 已经发生,还能够知道 panic 信息和产生 panic 的代码。
## 小结
这篇文章中,我们学习了 VGA 字符缓冲区的结构,以及如何在 `0xb8000` 的内存映射地址访问它。我们将所有的不安全操作包装为一个 Rust 模块,以便在外界安全地访问它。
我们也发现了——感谢便于使用的 cargo——在 Rust 中使用第三方提供的包是及其容易的。我们添加的两个依赖项,`lazy_static``spin`,都在操作系统开发中及其有用;我们将在未来的文章中多次使用它们。
## 下篇预告
下一篇文章中,我们将会讲述如何配置 Rust 内置的单元测试框架。我们还将为本文编写的 VGA 缓冲区模块添加基础的单元测试项目。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,473 +0,0 @@
+++
title = "استثناهای پردازنده"
weight = 5
path = "fa/cpu-exceptions"
date = 2018-06-17
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "a081faf3cced9aeb0521052ba91b74a1c408dcff"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
استثناهای پردازنده در موقعیت های مختلف دارای خطا رخ می دهد ، به عنوان مثال هنگام دسترسی به آدرس حافظه نامعتبر یا تقسیم بر صفر. برای واکنش به آنها ، باید یک _جدول توصیف کننده وقفه_ تنظیم کنیم که توابع کنترل کننده را فراهم کند. در انتهای این پست ، هسته ما قادر به گرفتن [استثناهای breakpoint] و ادامه اجرای طبیعی پس از آن خواهد بود.
[استثناهای breakpoint]: https://wiki.osdev.org/Exceptions#Breakpoint
<!-- more -->
این بلاگ بصورت آزاد روی [گیت‌هاب] توسعه داده شده است. اگر مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. همچنین می‌توانید [در زیر] این پست کامنت بگذارید. سورس کد کامل این پست را می‌توانید در بِرَنچ [`post-05`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-05
<!-- toc -->
## بررسی اجمالی
یک استثنا نشان می دهد که مشکلی در دستورالعمل فعلی وجود دارد. به عنوان مثال ، اگر دستورالعمل فعلی بخواهد تقسیم بر 0 کند ، پردازنده یک استثنا صادر می کند. وقتی یک استثنا اتفاق می افتد ، پردازنده کار فعلی خود را رها کرده و بسته به نوع استثنا ، بلافاصله یک تابع خاص کنترل کننده استثنا را فراخوانی می کند.
در x86 حدود 20 نوع مختلف استثنا پردازنده وجود دارد. مهمترین آنها در زیر آمده اند:
- **خطای صفحه**: خطای صفحه در دسترسی غیرقانونی به حافظه رخ می دهد. به عنوان مثال ، اگر دستورالعمل فعلی بخواهد از یک صفحه نگاشت نشده بخواند یا بخواهد در یک صفحه فقط خواندنی بنویسد.
- **کد نامعتبر**: این استثنا وقتی رخ می دهد که دستورالعمل فعلی نامعتبر است ، به عنوان مثال وقتی می خواهیم از [دستورالعمل های SSE] جدیدتر بر روی یک پردازنده قدیمی استفاده کنیم که آنها را پشتیبانی نمی کند.
- **خطای محافظت عمومی**: این استثنا دارای بیشترین دامنه علل است. این مورد در انواع مختلف نقض دسترسی مانند تلاش برای اجرای یک دستورالعمل ممتاز در کد سطح کاربر یا نوشتن فیلدهای رزرو شده در ثبات های پیکربندی رخ می دهد.
- **خطای دوگانه**: هنگامی که یک استثنا رخ می دهد ، پردازنده سعی می کند تابع کنترل کننده مربوطه را اجرا کند. اگر یک استثنا دیگر رخ دهد _هنگام فراخوانی تابع کنترل کننده استثنا_ ، پردازنده یک استثنای خطای دوگانه ایجاد می کند. این استثنا همچنین زمانی اتفاق می افتد که هیچ تابع کنترل کننده ای برای یک استثنا ثبت نشده باشد.
- **خطای سه‌گانه**: اگر در حالی که پردازنده سعی می کند تابع کنترل کننده خطای دوگانه را فراخوانی کند استثنایی رخ دهد ، این یک خطای سه‌گانه است. ما نمی توانیم یک خطای سه گانه را بگیریم یا آن را کنترل کنیم. بیشتر پردازنده ها ریست کردن خود و راه اندازی مجدد سیستم عامل واکنش نشان می دهند.
[دستورالعمل های SSE]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
برای مشاهده لیست کامل استثنا‌ها ، [ویکی OSDev][exceptions] را بررسی کنید.
[exceptions]: https://wiki.osdev.org/Exceptions
### جدول توصیف کننده وقفه
برای گرفتن و رسیدگی به استثنا‌ها ، باید اصطلاحاً _جدول توصیفگر وقفه_ (IDT) را تنظیم کنیم. در این جدول می توانیم برای هر استثنا پردازنده یک عملکرد تابع کننده مشخص کنیم. سخت افزار به طور مستقیم از این جدول استفاده می کند ، بنابراین باید از یک قالب از پیش تعریف شده پیروی کنیم. هر ورودی جدول باید ساختار 16 بایتی زیر را داشته باشد:
Type| Name | Description
----|--------------------------|-----------------------------------
u16 | Function Pointer [0:15] | The lower bits of the pointer to the handler function.
u16 | GDT selector | Selector of a code segment in the [global descriptor table].
u16 | Options | (see below)
u16 | Function Pointer [16:31] | The middle bits of the pointer to the handler function.
u32 | Function Pointer [32:63] | The remaining bits of the pointer to the handler function.
u32 | Reserved |
[global descriptor table]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
قسمت گزینه ها (Options) دارای قالب زیر است:
Bits | Name | Description
------|-----------------------------------|-----------------------------------
0-2 | Interrupt Stack Table Index | 0: Don't switch stacks, 1-7: Switch to the n-th stack in the Interrupt Stack Table when this handler is called.
3-7 | Reserved |
8 | 0: Interrupt Gate, 1: Trap Gate | If this bit is 0, interrupts are disabled when this handler is called.
9-11 | must be one |
12 | must be zero |
1314 | Descriptor Privilege Level (DPL) | The minimal privilege level required for calling this handler.
15 | Present |
هر استثنا دارای یک اندیس از پیش تعریف شده در IDT است. به عنوان مثال استثنا کد نامعتبر دارای اندیس 6 و استثنا خطای صفحه دارای اندیس 14 است. بنابراین ، سخت افزار می تواند به طور خودکار عنصر مربوطه را برای هر استثنا بارگذاری کند. [جدول استثناها][exceptions] در ویکی OSDev ، اندیس های IDT کلیه استثناها را در ستون “Vector nr.” نشان داده است.
هنگامی که یک استثنا رخ می دهد ، پردازنده تقریباً موارد زیر را انجام می دهد:
1. برخی از ثبات‌ها را به پشته وارد می‌کند، از جمله اشاره گر دستورالعمل و ثبات [RFLAGS]. (بعداً در این پست از این مقادیر استفاده خواهیم کرد.)
2. عنصر مربوط به آن (استثنا) را از جدول توصیف کننده وقفه (IDT) می‌خواند. به عنوان مثال ، پردازنده هنگام رخ دادن خطای صفحه ، عنصر چهاردهم را می خواند.
3. وجود عنصر را بررسی می‌کند. اگر اینگونه نباشد یک خطای دوگانه ایجاد می‌کند.
4. اگر عنصر یک گیت وقفه است (بیت 40 تنظیم نشده است) وقفه های سخت افزاری را غیرفعال می‌کند.
5. انتخابگر مشخص شده [GDT] را در سگمنت CS بارگذاری می‌کند.
6. به تابع کنترل کننده مشخص شده می‌رود.
[RFLAGS]: https://en.wikipedia.org/wiki/FLAGS_register
[GDT]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
در حال حاضر نگران مراحل 4 و 5 نباشید ، ما در مورد جدول توصیف کننده گلوبال و وقفه های سخت افزاری در پست های بعدی خواهیم آموخت.
## یک نوع IDT
به جای ایجاد نوع IDT خود ، از [ساختمان `InterruptDescriptorTable`] کرت `x86_64` استفاده خواهیم کرد که به این شکل است:
[ساختمان `InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
``` rust
#[repr(C)]
pub struct InterruptDescriptorTable {
pub divide_by_zero: Entry<HandlerFunc>,
pub debug: Entry<HandlerFunc>,
pub non_maskable_interrupt: Entry<HandlerFunc>,
pub breakpoint: Entry<HandlerFunc>,
pub overflow: Entry<HandlerFunc>,
pub bound_range_exceeded: Entry<HandlerFunc>,
pub invalid_opcode: Entry<HandlerFunc>,
pub device_not_available: Entry<HandlerFunc>,
pub double_fault: Entry<HandlerFuncWithErrCode>,
pub invalid_tss: Entry<HandlerFuncWithErrCode>,
pub segment_not_present: Entry<HandlerFuncWithErrCode>,
pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
pub page_fault: Entry<PageFaultHandlerFunc>,
pub x87_floating_point: Entry<HandlerFunc>,
pub alignment_check: Entry<HandlerFuncWithErrCode>,
pub machine_check: Entry<HandlerFunc>,
pub simd_floating_point: Entry<HandlerFunc>,
pub virtualization: Entry<HandlerFunc>,
pub security_exception: Entry<HandlerFuncWithErrCode>,
// some fields omitted
}
```
فیلدها از نوع [`<idt::Entry<F`] هستند ، این ساختمانی است که فیلد های یک عنصر IDT را نشان می دهد (به جدول بالا مراجعه کنید). پارامتر نوع `F`، نوع تابع کنترل کننده مورد انتظار را تعریف می کند. می بینیم که برخی از عناصر به یک [`HandlerFunc`] و برخی دیگر به [`HandlerFuncWithErrCode`] نیاز دارند. خطای صفحه حتی نوع خاص خود را دارد: [`PageFaultHandlerFunc`].
[`<idt::Entry<F`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.Entry.html
[`HandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFunc.html
[`HandlerFuncWithErrCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFuncWithErrCode.html
[`PageFaultHandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.PageFaultHandlerFunc.html
بیایید ابتدا به نوع `HandlerFunc` نگاه کنیم:
```rust
type HandlerFunc = extern "x86-interrupt" fn(_: InterruptStackFrame);
```
این یک [نوع مستعار(type alias)] برای نوع "`extern "x86-interrupt" fn` است. کلمه کلیدی `extern` تابعی را با یک [قرارداد فراخوانی خارجی] تعریف می کند و اغلب برای برقراری ارتباط با کد C استفاده می شود(`extern "C" fn`) . اما قرارداد فراخوانی `x86-interrupt` چیست؟
[نوع مستعار(type alias)]: https://doc.rust-lang.org/book/ch19-04-advanced-types.html#creating-type-synonyms-with-type-aliases
[قرارداد فراخوانی خارجی]: https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions
## قرارداد فراخوانی وقفه
استثنا‌ها کاملاً شبیه فراخوانی توابع هستند: پردازنده به اولین دستورالعمل تابع فراخوانی شده می رود و آن را اجرا می کند. پس از آن پردازنده به آدرس بازگشت می پرد و اجرای تابع اصلی را ادامه می دهد.
با این وجود ، تفاوت عمده ای بین فراخوانی استثناها و توابع وجود دارد: یک فراخوانی تابع توسط یک کامپایلر که دستور "فراخوانی" در آن درج شده است ، انجام می شود ، در حالی که یک استثنا ممکن است در _هر_ دستورالعملی رخ دهد. برای درک عواقب این تفاوت ، باید فراخوانی توابع را با جزئیات بیشتری بررسی کنیم.
[قرارداد فراخوانی] جزئیات فراخوانی تابع را مشخص می کند. به عنوان مثال ، آنها مشخص می‌کنند که پارامترهای تابع کجا قرار می گیرند (به عنوان مثال در ثبات‌ها یا بر روی پشته) و نحوه بازگشت نتایج. در x86_64 لینوکس ، قوانین زیر برای توابع C اعمال می شود (مشخص شده در [System V ABI]):
[قرارداد فراخوانی]: https://en.wikipedia.org/wiki/Calling_convention
[System V ABI]: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
- شش آرگومان اول با نوع عدد صحیح در ثبات‌های`rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9` منتقل می شوند
- آرگومان های اضافی بر روی پشته منتقل می شوند
- نتایج درون`rax` و `rdx` بر می گردند
توجه داشته باشید که راست از C ABI پیروی نمی کند (در واقع ، [هنوز حتی یک Rust ABI وجود ندارد][rust abi]) ، بنابراین این قوانین فقط برای توابع اعلام شده به عنوان `extern "C" fn` اعمال می شود.
[rust abi]: https://github.com/rust-lang/rfcs/issues/600
### ثبات های حفظ شده و تغییرشونده (Scratch)
قرارداد فراخوانی، ثبات‌ها را به دو دسته ثبات های _محفوظ شده_ و _تغییرشونده_ تقسیم می کند.
مقادیر ثبات‌های _محفوظ شده_، در فراخوانی تابع باید بدون تغییر باقی بمانند. بنابراین یک تابع فراخوانی شده (_“callee”_) فقط در صورتی مجاز است این ثبات‌ها را تغییر دهد، که مقادیر اصلی آنها را قبل از بازگشت، برگرداند. بنابراین به این ثبات‌ها _"callee-saved"_ گفته می شود. یک الگوی عمومی این است که ثبات‌ها در آغاز تابع بر روی پشته ذخیره شده و درست قبل از بازگشت از پشته برداشته شده و مقدار دهی شوند.
در مقابل، یک تابع فراخوانی شده مجاز است که بدون محدودیت ، ثبات‌های _تغییرشونده_ را دوباره بنویسد. اگر فراخواننده (_"caller"_) بخواهد مقدار یک ثبات تغییرشونده را در یک فراخوانی تابع حفظ کند ، لازم است قبل از فراخوانی تابع (به عنوان مثال بوسیله اضافه و برداشتن از روی پشته) آن را پشتیبان گیری و بازیابی کند. بنابراین ثبات‌های تغییرشونده _caller-saved_ هستند.
در x86_64 ، قرارداد فراخوانی C ثبات‌های محفوظ شده و تغییرشونده زیر را مشخص می کند:
preserved registers | scratch registers
---|---
`rbp`, `rbx`, `rsp`, `r12`, `r13`, `r14`, `r15` | `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11`
_callee-saved_ | _caller-saved_
کامپایلر این قوانین را می داند ، بنابراین کد را متناسب با آن تولید می کند. به عنوان مثال ، بیشتر توابع با `push rbp` شروع می شوند که پشتیبان گیری از`rbp` روی پشته است (زیرا این یک ثبات _caller-saved_).
### حفظ کلیه ثبات‌ها
برخلاف فراخوانی تابع ، استثنا‌ها می توانند در _هر_ دستورالعملی رخ دهند. در بیشتر موارد ، ما حتی در زمان کامپایل نمی دانیم که کد تولید شده استثنا ایجاد می کند یا نه. به عنوان مثال ، کامپایلر نمی تواند بداند که آیا یک دستورالعمل باعث سرریز شدن پشته یا خطای صفحه می شود.
از آنجا که نمی دانیم چه زمانی استثنا رخ می‌دهد ، نمی توانیم قبل از آن از هیچ ثباتی پشتیبان گیری کنیم. این بدان معناست که ما نمی توانیم از قرارداد فراخوانی‌ای استفاده کنیم که متکی به ثبات‌های caller-saved برای کنترل کننده های استثنا هست. در عوض ، به یک قرارداد فراخوانی نیاز داریم که _همه_ ثبات‌ها را حفظ کند. قرارداد فراخوانی `x86-interrupt` چنین قرارداد فراخوانی است ، بنابراین تضمین می کند که تمام مقادیر ثبات‌ها در هنگام بازگشت تابع به مقادیر اصلی خود بازگردند.
توجه داشته باشید که این بدان معنا نیست که همه ثبات‌ها در ورود به تابع در پشته ذخیره می شوند. در عوض ، کامپایلر فقط از ثبات‌هایی که توسط تابع تغییر می‌کنند ، پشتیبان تهیه می کند. به این ترتیب ، کد بسیار کارآمدی برای توابع کوتاه که فقط از چند ثبات استفاده می کنند ، تولید می شود.
### قاب پشته وقفه (The Interrupt Stack Frame)
در یک فراخوانی عادی تابع (با استفاده از دستورالعمل `call`) ، پردازنده قبل از پرش به تابع هدف ، آدرس بازگشت را در پشته ذخیره می‌کند. در هنگام بازگشت تابع (با استفاده از دستورالعمل `ret`) ، پردازنده این آدرس بازگشت را از پشته برمی‌دارد و به آن می پرد. بنابراین قاب پشته یک فراخوانی عادی تابع به این شکل است:
![function stack frame](function-stack-frame.svg)
با این وجود، برای کنترل کننده های استثنا و وقفه، ذخیره آدرس برگشت در پشته کافی نیست، زیرا کنترل کننده های وقفه غالباً در context دیگری اجرا می شوند (نشانگر پشته ، پرچم های پردازنده و غیره). در عوض، پردازنده در صورت وقفه مراحل زیر را انجام می دهد:
1. **تراز کردن اشاره‌گر پشته**: در هر دستورالعمل امکان رخ دادن وقفه وجود دارد، بنابراین اشاره‌گر پشته نیز می تواند هر مقداری داشته باشد. با این حال ، برخی از دستورالعمل های پردازنده (به عنوان مثال برخی از دستورالعمل های SSE) نیاز دارند که اشاره‌گر پشته در مرز 16 بایت تراز شود ، بنابراین پردازنده درست پس از وقفه چنین ترازی را انجام می دهد.
2. **تعویض پشته‌ها** (در بعضی موارد): تعویض پشته زمانی اتفاق می افتد که سطح امتیاز پردازنده (CPU privilege level) تغییر می کند، به عنوان مثال وقتی یک استثنا در یک برنامه حالت کاربر رخ می دهد. همچنین می توان تعویض پشته را برای وقفه های خاص با استفاده از به اصطلاح _Interrupt Stack Table_ پیکربندی کرد (در پست بعدی توضیح داده شده).
3. **پوش کردن اشاره‌گر قدیمی پشته**: پردازنده مقادیر اشاره‌گر پشته (`rsp`) و سگمنت پشته (`ss`) را در زمان وقوع وقفه (قبل از تراز کردن) پوش می‌کند. این امکان را فراهم می کند تا هنگام بازگشت از کنترل کننده وقفه ، اشاره‌گر اصلی پشته بازیابی شود.
4. **پوش کردن و به‌روزرسانی ثبات `RFLAGS`**: ثبات [`RFLAGS`] شامل بیت های مختلف کنترل و وضعیت است. در هنگام وقوع وقفه ، پردازنده برخی از بیت‌ها را تغییر می‌دهد و مقدار قدیمی را پوش می‌کند.
5. **پوش کردن اشاره‌گر دستورالعمل**: قبل از پرش به تابع کنترل کننده وقفه ، پردازنده اشاره‌گر دستورالعمل (`rip`) و سگمنت کد (`cs`) را پوش می‌کند. این مشابه با پوش کردن آدرس برگشت یک تابع عادی است.
6. ** پوش کردن کد خطا** (برای برخی استثناها): برای برخی از استثنا های خاص مانند خطاهای صفحه ، پردازنده یک کد خطا را پوش می‌کند که علت استثنا را توصیف می کند.
7. **فراخوانی کنترل کننده وقفه**: پردازنده آدرس و توصیف کننده سگمنت تابع کنترل کننده وقفه را از قسمت مربوطه در IDT می خواند. سپس با بارگذاری مقادیر در ثبات های `rip` و `cs` این کنترل کننده را فراخوانی می کند.
[`RFLAGS`]: https://en.wikipedia.org/wiki/FLAGS_register
بنابراین _interrupt stack frame_ به این شکل است:
![interrupt stack frame](exception-stack-frame.svg)
در کرت `x86_64` ، فریم پشته وقفه توسط ساختمان [`InterruptStackFrame`] نشان داده می شود. این ساختمان به عنوان `&mut` به کنترل کننده وقفه منتقل می شود و می تواند برای دریافت اطلاعات بیشتر در مورد علت استثنا استفاده شود. ساختمان بدون فیلد کد خطا است ، زیرا فقط برخی از استثناها کد خطا را پوش می‌کنند. این استثناها از نوع تابع جداگانه [`HandlerFuncWithErrCode`] استفاده می‌کنند ، که دارای یک آرگومان اضافی `error_code` است.
[`InterruptStackFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptStackFrame.html
### پشت صحنه
قرارداد فراخوانی `x86-interrupt` یک انتزاع قدرتمند است که تقریباً تمام جزئیات پیچیده فرآیند مدیریت استثناها را پنهان می کند. با این حال ، گاهی اوقات مفید است که بدانیم پشت پرده چه اتفاقی می افتد. در اینجا یک مرور کوتاه از مواردی که قرارداد فراخوانی `x86-interrupt` انجام می‌دهد را می‌بینید:
- **دریافت آرگومان ها**: بیشتر قرارداد های فراخوانی انتظار دارند که آرگومان ها در ثبات‌ها منتقل شوند. این برای کنترل کننده های استثنا امکان پذیر نیست ، زیرا ما نباید قبل از تهیه نسخه پشتیبان از مقادیر ثبات‌ها ، آنها را بازنویسی کنیم. در عوض، قرارداد فراخوانی `x86-interrupt` آگاه است که آرگومان ها از قبل در مکان خاصی بر روی پشته قرار دارند.
- **بازگشت با استفاده از `iretq`**: از آنجا که قاب پشته وقفه با قاب پشته صدا زدن توابع معمولی کاملاً متفاوت است، نمی توانیم از طریق دستورالعمل` ret` از توابع کنترل کننده برگردیم. در عوض، باید از دستور `iretq` استفاده شود.
- **مدیریت کد خطا**: کد خطا که برای برخی استثناها به پشته اضافه می شود ، کارها را بسیار پیچیده تر می کند. تراز بندی پشته را تغییر می دهد (به قسمت بعدی مراجعه کنید) و باید قبل از بازگشت، از پشته خارج شود. قرارداد فراخوانی `x86-interrupt` تمام پیچیدگی‌ها را برطرف می کند. با این حال، نمی داند کدام تابع کنترل کننده برای کدام استثنا استفاده می شود، بنابراین باید این اطلاعات را از تعداد آرگومان های تابع استخراج کند. این بدان معناست که برنامه نویس همچنان مسئول استفاده صحیح هر نوع تابع برای هر استثنا است. خوشبختانه نوع `InterruptDescriptorTable` که توسط کرت `x86_64` تعریف شده است، استفاده از انواع تابع صحیح را تضمین می کند.
- **تراز کردن پشته**: برخی دستورالعمل‌ها (به ویژه دستورالعمل های SSE) وجود دارند که به یک تراز پشته 16 بایتی نیاز دارند. پردازنده این تراز را هر زمان که یک استثنا اتفاق می افتد تضمین می کند ، اما برای برخی از استثناها بعداً هنگامی که یک کد خطا را به پشته اضافه می‌کند، دوباره آن را از بین می برد. قرارداد فراخوانی `x86-interrupt` با تنظیم مجدد پشته در این حالت این مشکل را برطرف می‌کند.
اگر به جزئیات بیشتر علاقه مندید: ما همچنین یک سری پست داریم که مدیریت استثنا با استفاده از [توابع برهنه] را توضیح می‌دهند. ([در انتهای این پست][too-much-magic]).
[توابع برهنه]: https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md
[too-much-magic]: #khyly-jdwyy-bwd
## پیاده سازی
اکنون که تئوری را فهمیدیم ، وقت آن رسیده است که استثناهای پردازنده را در هسته خود کنترل کنیم. ما با ایجاد یک ماژول جدید وقفه‌ها در `src/interrupts.rs` شروع خواهیم کرد، که ابتدا یک تابع `init_idt` ایجاد می کند که یک `InterruptDescriptorTable` جدید ایجاد می کند:
``` rust
// in src/lib.rs
pub mod interrupts;
// in src/interrupts.rs
use x86_64::structures::idt::InterruptDescriptorTable;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
}
```
اکنون می توانیم توابع کنترل کننده را اضافه کنیم. با اضافه کردن یک کنترل کننده برای [استثنا بریک‌پوینت] شروع می کنیم. استثنای بریک‌پوینت، استثنای عالی برای آزمایش مدیریت استثنا است. تنها هدف آن مکث موقت برنامه هنگام اجرای دستورالعمل بریک‌پوینت `int3` است.
[استثنا بریک‌پوینت]: https://wiki.osdev.org/Exceptions#Breakpoint
استثنا بریک‌پوینت معمولاً در دیباگرها به کار می رود: وقتی کاربر بریک‌پوینت را تعیین می کند ، دیباگر دستورالعمل مربوطه را با دستورالعمل `int3` بازنویسی می کند تا پردازنده هنگام رسیدن به آن خط، استثنای بریک‌پوینت را ایجاد کند. هنگامی که کاربر می خواهد برنامه را ادامه دهد، دیباگر دوباره دستورالعمل `int3` را با دستورالعمل اصلی جایگزین می کند و برنامه را ادامه می دهد. برای جزئیات بیشتر ، سری ["_دیباگرها چطور کار میکنند_"] را ببینید.
["_دیباگرها چطور کار میکنند_"]: https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
برای موارد استفاده ما، نیازی به بازنویسی دستورالعمل نداریم. در عوض، فقط می‌خواهیم هنگام اجرای دستورالعمل بریک‌پوینت پیامی چاپ کنیم و سپس برنامه را ادامه دهیم. بنابراین بیایید یک تابع ساده `breakpoint_handler` ایجاد کنیم و آن را به IDT خود اضافه کنیم:
```rust
// in src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
}
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame)
{
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
```
کنترل کننده ما فقط یک پیام را خارج می کند و قاب پشته وقفه را زیبا چاپ می کند.
هنگامی که می خواهیم آن را کامپایل کنیم ، خطای زیر رخ می دهد:
```
error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180)
--> src/main.rs:53:1
|
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
54 | | println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
55 | | }
| |_^
|
= help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable
```
این خطا به این دلیل رخ می دهد که قرارداد فراخوانی `x86-interrupt` هنوز ناپایدار است. به هر حال برای استفاده از آن ، باید صریحاً آن را با اضافه کردن `#![feature(abi_x86_interrupt)]` در بالای `lib.rs` فعال کنیم.
### بارگیری IDT
برای اینکه پردازنده از جدول توصیف کننده وقفه جدید ما استفاده کند ، باید آن را با استفاده از دستورالعمل [`lidt`] بارگیری کنیم. ساختمان `InterruptDescriptorTable` از کرت ` x86_64` متد [`load`][InterruptDescriptorTable::load] را برای این کار فراهم می کند. بیایید سعی کنیم از آن استفاده کنیم:
[`lidt`]: https://www.felixcloutier.com/x86/lgdt:lidt
[InterruptDescriptorTable::load]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html#method.load
```rust
// in src/interrupts.rs
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.load();
}
```
اکنون هنگامی که می خواهیم آن را کامپایل کنیم ، خطای زیر رخ می دهد:
```
error: `idt` does not live long enough
--> src/interrupts/mod.rs:43:5
|
43 | idt.load();
| ^^^ does not live long enough
44 | }
| - borrowed value only lives until here
|
= note: borrowed value must be valid for the static lifetime...
```
پس متد `load` انتظار دریافت یک `static self'&` را دارد، این مرجعی است که برای تمام مدت زمان اجرای برنامه معتبر است. دلیل این امر این است که پردازنده در هر وقفه به این جدول دسترسی پیدا می کند تا زمانی که IDT دیگری بارگیری کنیم. بنابراین استفاده از طول عمر کوتاه تر از `static'` می تواند منجر به باگ های استفاده-بعد-از-آزادسازی شود.
در واقع ، این دقیقاً همان چیزی است که در اینجا اتفاق می افتد. `idt` ما روی پشته ایجاد می شود ، بنابراین فقط در داخل تابع `init` معتبر است. پس از آن حافظه پشته برای توابع دیگر مورد استفاده مجدد قرار می گیرد ، بنابراین پردازنده حافظه پشته تصادفی را به عنوان IDT تفسیر می کند. خوشبختانه ، متد `InterruptDescriptorTable::load` این نیاز به طول عمر را در تعریف تابع خود اجباری می کند، بنابراین کامپایلر راست قادر است از این مشکل احتمالی در زمان کامپایل جلوگیری کند.
برای رفع این مشکل، باید `idt` را در مکانی ذخیره کنیم که طول عمر `static'` داشته باشد. برای رسیدن به این هدف می توانیم IDT را با استفاده از [`Box`] بر روی حافظه Heap ایجاد کنیم و سپس آن را به یک مرجع `static'` تبدیل کنیم، اما ما در حال نوشتن هسته سیستم عامل هستیم و بنابراین هنوز Heap نداریم.
[`Box`]: https://doc.rust-lang.org/std/boxed/struct.Box.html
به عنوان یک گزینه دیگر، می توانیم IDT را به صورت `static` ذخیره کنیم:
```rust
static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
```
با این وجود، یک مشکل وجود دارد: استاتیک‌ها تغییرناپذیر هستند، پس نمی توانیم ورودی بریک‌پوینت را از تابع `init` تغییر دهیم. می توانیم این مشکل را با استفاده از [`static mut`] حل کنیم:
[`static mut`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
```rust
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
unsafe {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
}
```
در این روش بدون خطا کامپایل می شود اما مشکلات دیگری به همراه دارد. `static mut` بسیار مستعد Data Race هستند، بنابراین در هر دسترسی به یک [بلوک `unsafe`] نیاز داریم.
[بلوک `unsafe`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers
#### Lazy Statics به نجات ما می‌آیند
خوشبختانه ماکرو `lazy_static` وجود دارد. ماکرو به جای ارزیابی یک `static` در زمان کامپایل ، مقداردهی اولیه آن را هنگام اولین ارجاع به آن انجام می دهد. بنابراین، می توانیم تقریباً همه کاری را در بلوک مقداردهی اولیه انجام دهیم و حتی قادر به خواندن مقادیر زمان اجرا هستیم.
ما قبلاً کرت `lazy_static` را وارد کردیم وقتی [یک انتزاع برای بافر متن VGA ایجاد کردیم][vga text buffer lazy static]. بنابراین می توانیم مستقیماً از ماکرو `!lazy_static` برای ایجاد IDT استاتیک استفاده کنیم:
[vga text buffer lazy static]: @/edition-2/posts/03-vga-text-buffer/index.md#lazy-statics
```rust
// in src/interrupts.rs
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
```
توجه داشته باشید که چگونه این راه حل به هیچ بلوک `unsafe` نیاز ندارد. ماکرو `!lazy_static` از `unsafe` در پشت صحنه استفاده می کند ، اما در یک رابط امن به ما داده می شود.
### اجرای آن
آخرین مرحله برای کارکرد استثناها در هسته ما فراخوانی تابع `init_idt` از `main.rs` است. به جای فراخوانی مستقیم آن، یک تابع عمومی `init` را در `lib.rs` معرفی می کنیم:
```rust
// in src/lib.rs
pub fn init() {
interrupts::init_idt();
}
```
با استفاده از این تابع اکنون یک مکان اصلی برای روالهای اولیه داریم که می تواند بین توابع مختلف `start_` در `main.rs` ، `lib.rs` و تست‌های یک‌پارچه به اشتراک گذاشته شود.
اکنون می توانیم تابع `start_` در `main.rs` را به روز کنیم تا `init` را فراخوانی کرده و سپس یک استثنا بریک‌پوینت ایجاد کند:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init(); // new
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3(); // new
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
اکنون هنگامی که آن را در QEMU اجرا می کنیم (با استفاده از `cargo run`) ، موارد زیر را مشاهده می کنیم:
![QEMU printing `EXCEPTION: BREAKPOINT` and the interrupt stack frame](qemu-breakpoint-exception.png)
کار می کند! پردازنده با موفقیت تابع کنترل کننده بریک‌پوینت ما را فراخوانی می کند ، که پیام را چاپ می کند و سپس به تابع `start_` برمی گردد ، جایی که پیام `!It did not crash` چاپ شده است.
می بینیم که قاب پشته وقفه، دستورالعمل و نشانگرهای پشته را در زمان وقوع استثنا به ما می گوید. این اطلاعات هنگام رفع اشکال استثناهای غیر منتظره بسیار مفید است.
### افزودن یک تست
بیایید یک تست ایجاد کنیم که از ادامه کار کد بالا اطمینان حاصل کند. ابتدا تابع `start_` را به روز می کنیم تا `init` را نیز فراخوانی کند:
```rust
// in src/lib.rs
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init(); // new
test_main();
loop {}
}
```
بخاطر داشته باشید، این تابع `start_` هنگام اجرای`cargo test --lib` استفاده می شود، زیرا راست `lib.rs` را کاملاً مستقل از`main.rs` تست می‌کند. قبل از اجرای تست‌ها باید برای راه اندازی IDT در اینجا `init` فراخوانی شود.
اکنون می توانیم یک تست `test_breakpoint_exception` ایجاد کنیم:
```rust
// in src/interrupts.rs
#[test_case]
fn test_breakpoint_exception() {
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3();
}
```
این تست تابع `int3` را فراخوانی می کند تا یک استثنا بریک‌پوینت ایجاد کند. با بررسی اینکه اجرا پس از آن ادامه دارد ، تأیید می کنیم که کنترل کننده بریک‌پوینت ما به درستی کار می کند.
شما می توانید این تست جدید را با اجرای `cargo test` (همه تست‌ها) یا` cargo test --lib` (فقط تست های `lib.rs` و ماژول های آن) امتحان کنید. باید موارد زیر را در خروجی مشاهده کنید:
```
blog_os::interrupts::test_breakpoint_exception... [ok]
```
## خیلی جادویی بود؟
قرارداد فراخوانی `x86-interrupt` و نوع [`InterruptDescriptorTable`] روند مدیریت استثناها را نسبتاً سر راست و بدون درد ساخته‌اند. اگر این برای شما بسیار جادویی بود و دوست دارید تمام جزئیات مهم مدیریت استثنا را بیاموزید، برای شما هم مطالبی داریم: مجموعه ["مدیریت استثناها با توابع برهنه"] ما، نحوه مدیریت استثنا‌ها بدون قرارداد فراخوانی`x86-interrupt` را نشان می دهد و همچنین نوع IDT خاص خود را ایجاد می کند. از نظر تاریخی، این پست‌ها مهمترین پست‌های مدیریت استثناها قبل از وجود قرارداد فراخوانی `x86-interrupt` و کرت `x86_64` بودند. توجه داشته باشید که این پست‌ها بر اساس [نسخه اول] این وبلاگ هستند و ممکن است قدیمی باشند.
["مدیریت استثناها با توابع برهنه"]: @/edition-1/extra/naked-exceptions/_index.md
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[نسخه اول]: @/edition-1/_index.md
## مرحله بعدی چیست؟
ما اولین استثنای خود را با موفقیت گرفتیم و از آن بازگشتیم! گام بعدی اطمینان از این است که همه استثناها را می گیریم ، زیرا یک استثنا گرفته نشده باعث [خطای سه‌گانه] می شود که منجر به شروع مجدد سیستم می شود. پست بعدی توضیح می دهد که چگونه می توان با گرفتن صحیح [خطای دوگانه] از این امر جلوگیری کرد.
[خطای سه‌گانه]: https://wiki.osdev.org/Triple_Fault
[خطای دوگانه]: https://wiki.osdev.org/Double_Fault#Double_Fault

View File

@@ -1,471 +0,0 @@
+++
title = "CPU例外"
weight = 5
path = "ja/cpu-exceptions"
date = 2018-06-17
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "a8a6b725cff2e485bed76ff52ac1f18cec08cc7b"
# GitHub usernames of the people that translated this post
translators = ["woodyZootopia"]
+++
CPU例外は、例えば無効なメモリアドレスにアクセスしたときやゼロ除算したときなど、様々なミスによって発生します。それらに対処するために、ハンドラ関数を提供する **<ruby>割り込み記述子表<rp> (</rp><rt>interrupt descriptor table</rt><rp>) </rp></ruby>** を設定しなくてはなりません。この記事を読み終わる頃には、私達のカーネルは[ブレークポイント例外][breakpoint exceptions]を捕捉し、その後通常の実行を継続できるようになっているでしょう。
[breakpoint exceptions]: https://wiki.osdev.org/Exceptions#Breakpoint
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-05` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-05
<!-- toc -->
## 概要
例外とは、今実行している命令はなにかおかしいぞ、ということを示すものです。例えば、現在の命令がゼロ除算を実行しようとしているとき、CPUは例外を発します。例外が起こると、CPUは現在行われている作業に割り込み、例外の種類に従って、即座に特定の例外ハンドラ関数を呼びます。
x86には20種類のCPU例外があります。中でも重要なものは
- **<ruby>ページフォルト<rp> (</rp><rt>Page Fault</rt><rp>) </rp></ruby>**: ページフォルトは不正なメモリアクセスの際に発生します。例えば、現在の命令がマップされていないページから読み込もうとしたり、読み込み専用のページに書き込もうとしたときに生じます。
- **<ruby>無効な<rp> (</rp><rt>Invalid</rt><rp>) </rp></ruby><ruby>命令コード<rp> (</rp><rt>Opcode</rt><rp>) </rp></ruby>**: この例外は現在の命令が無効であるときに発生します。例えば、[SSE命令][SSE instructions]という新しい命令をサポートしていない旧式のCPU上でこれを実行しようとしたときに生じます。
- **<ruby>一般保護違反<rp> (</rp><rt>General Protection Fault</rt><rp>) </rp></ruby>**: これは、例外の中でも、最もいろいろな理由で発生しうるものです。ユーザーレベルのコードで<ruby>特権命令<rp> (</rp><rt>privileged instruction</rt><rp>) </rp></ruby>を実行しようとしたときや、設定レジスタの保護領域に書き込もうとしたときなど、様々な種類のアクセス違反によって生じます。
- **<ruby>ダブルフォルト<rp> (</rp><rt>Double Fault</rt><rp>) </rp></ruby>**: 何らかの例外が起こったとき、CPUは対応するハンドラ関数を呼び出そうとします。 この例外ハンドラを **呼び出している間に** 別の例外が起こった場合、CPUはダブルフォルト例外を出します。この例外はまた、ある例外に対してハンドラ関数が登録されていないときにも起こります。
- **<ruby>トリプルフォルト<rp> (</rp><rt>Triple Fault</rt><rp>) </rp></ruby>**: CPUがダブルフォルトのハンドラ関数を呼び出そうとしている間に例外が発生すると、CPUは **トリプルフォルト** という致命的な例外を発します。トリプルフォルトを捕捉したり処理したりすることはできません。これが起こると、多くのプロセッサは自らをリセットしてOSを再起動することで対応します。
[SSE instructions]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
例外の完全な一覧を見たい場合は、[OSDev wiki][exceptions]を見てください。
[exceptions]: https://wiki.osdev.org/Exceptions
### 割り込み記述子表
例外を捕捉し処理するためには、いわゆる割り込み記述子表 (Interrupt Descriptor Table, IDT) を設定しないといけません。この表にそれぞれのCPU例外に対するハンドラ関数を指定することができます。ハードウェアはこの表を直接使うので、決められたフォーマットに従わないといけません。それぞれのエントリは以下の16バイトの構造を持たなければなりません
型 | 名前 | 説明
----|--------------------------|-----------------------------------
u16 | 関数ポインタ [0:15] | ハンドラ関数へのポインタの下位ビット。
u16 | GDTセレクタ | [大域記述子表 (Global Descriptor Table)][global descriptor table] におけるコードセグメントのセレクタ。
u16 | オプション | (下を参照)
u16 | 関数ポインタ [16:31] | ハンドラ関数へのポインタの中位ビット。
u32 | 関数ポインタ [32:63] | ハンドラ関数へのポインタの上位ビット。
u32 | 予約済 |
[global descriptor table]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
オプション部は以下のフォーマットになっています:
ビット | 名前 | 説明
--------|------------------------------------------------------------------------------------------------|-------------------------------------
0-2 | 割り込みスタックテーブルインデックス | 0ならスタックを変えない。1から7なら、ハンドラが呼ばれたとき、割り込みスタック表のその数字のスタックに変える。
3-7 | 予約済 |
8 | 0: 割り込みゲート、1: トラップゲート | 0なら、このハンドラが呼ばれたとき割り込みは無効化される。
9-11 | 1にしておかないといけない |
12 | 0にしておかないといけない |
1314 | <ruby>記述子の特権レベル<rp> (</rp><rt>Descriptor Privilege Level</rt><rp>) </rp></ruby> (DPL) | このハンドラを呼ぶ際に必要になる最低限の特権レベル。
15 | Present |
それぞれの例外がIDTの何番目に対応するかは事前に定義されています。例えば、「無効な命令コード」の例外は6番目で、「ページフォルト」例外は14番目です。これにより、ハードウェアがそれぞれの例外に対応するIDTの設定を特に設定の必要なく自動的に読み出せるというわけです。OSDev wikiの[「例外表」][exceptions]の "Vector nr." 列に、すべての例外についてIDTの何番目かが記されています。
例外が起こると、ざっくりCPUは以下のことを行います
1. 命令ポインタと[RFLAGS]レジスタ(これらの値は後で使います)を含むレジスタをスタックにプッシュする。
2. 割り込み記述子表から対応するエントリを読む。例えば、ページフォルトが起こったときはCPUは14番目のエントリを読む。
3. エントリが存在しているのかチェックする。そうでなければダブルフォルトを起こす。
4. エントリが割り込みゲートなら40番目のビットが0ならハードウェア割り込みを無効にする。
5. 指定された[GDT]セレクタをCSセグメントに読み込む。
6. 指定されたハンドラ関数にジャンプする。
[RFLAGS]: https://en.wikipedia.org/wiki/FLAGS_register
[GDT]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
ステップ4と5について今深く考える必要はありません。今後の記事で大域記述子表 (Global Descriptor Table, 略してGDT) とハードウェア割り込みについては学んでいきます。
## IDT型
自前でIDTの型を作る代わりに、`x86_64`クレートの[`InterruptDescriptorTable`構造体][`InterruptDescriptorTable` struct]を使います。こんな見た目をしています:
[`InterruptDescriptorTable` struct]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
``` rust
#[repr(C)]
pub struct InterruptDescriptorTable {
pub divide_by_zero: Entry<HandlerFunc>,
pub debug: Entry<HandlerFunc>,
pub non_maskable_interrupt: Entry<HandlerFunc>,
pub breakpoint: Entry<HandlerFunc>,
pub overflow: Entry<HandlerFunc>,
pub bound_range_exceeded: Entry<HandlerFunc>,
pub invalid_opcode: Entry<HandlerFunc>,
pub device_not_available: Entry<HandlerFunc>,
pub double_fault: Entry<HandlerFuncWithErrCode>,
pub invalid_tss: Entry<HandlerFuncWithErrCode>,
pub segment_not_present: Entry<HandlerFuncWithErrCode>,
pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
pub page_fault: Entry<PageFaultHandlerFunc>,
pub x87_floating_point: Entry<HandlerFunc>,
pub alignment_check: Entry<HandlerFuncWithErrCode>,
pub machine_check: Entry<HandlerFunc>,
pub simd_floating_point: Entry<HandlerFunc>,
pub virtualization: Entry<HandlerFunc>,
pub security_exception: Entry<HandlerFuncWithErrCode>,
// いくつかのフィールドは省略している
}
```
この構造体のフィールドは[`idt::Entry<F>`]という型を持っています。これはIDTのエントリのフィールド上の表を見てくださいを表す構造体です。型パラメータ`F`は、期待されるハンドラ関数の型を表します。エントリの中には、[`HandlerFunc`]型を要求するものや、[`HandlerFuncWithErrCode`]型を要求するものがあることがわかります。ページフォルトに至っては、[`PageFaultHandlerFunc`]という自分専用の型を要求していますね。
[`idt::Entry<F>`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.Entry.html
[`HandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFunc.html
[`HandlerFuncWithErrCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFuncWithErrCode.html
[`PageFaultHandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.PageFaultHandlerFunc.html
まず`HandlerFunc`型を見てみましょう:
```rust
type HandlerFunc = extern "x86-interrupt" fn(_: InterruptStackFrame);
```
これは、`extern "x86-interrupt" fn`型への[型エイリアス][type alias]です。`extern`は[外部呼び出し規約][foreign calling convention]に従う関数を定義するのに使われ、おもにC言語のコードと連携したいときに使われます (`extern "C" fn`) 。しかし、`x86-interrupt`呼び出し規約とは何なのでしょう?
[type alias]: https://doc.rust-lang.org/book/ch19-04-advanced-types.html#creating-type-synonyms-with-type-aliases
[foreign calling convention]: https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions
## 例外の呼び出し規約
例外は関数呼び出しと非常に似ています。CPUが呼び出された関数の最初の命令にジャンプし、それを実行します。その後、CPUはリターンアドレスにジャンプし、親関数の実行を続けます。
しかし、例外と関数呼び出しには大きな違いが一つあるのです:関数呼び出しはコンパイラによって挿入された`call`命令によって自発的に引き起こされますが、例外は **どんな命令の実行中でも** 起こる可能性があるのです。この違いの結果を理解するためには、関数呼び出しについてより詳しく見ていく必要があります。
[呼び出し規約][Calling conventions]は関数呼び出しについて事細かく指定しています。例えば、関数のパラメータがどこに置かれるべきか例えば、レジスタなのかスタックなのかや、結果がどのように返されるべきかを指定しています。x86_64上のLinuxでは、C言語の関数に関しては以下のルールが適用されますこれは[System V ABI]で指定されています):
[Calling conventions]: https://en.wikipedia.org/wiki/Calling_convention
[System V ABI]: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
- 最初の6つの整数引数は、レジスタ`rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9`で渡される
- 追加の引数はスタックで渡される
- 結果は`rax`と`rdx`で返される
注意してほしいのは、RustはC言語のABIに従っていない実は、[RustにはABIすらまだありません][rust abi])ので、このルールは`extern "C" fn`と宣言された関数にしか適用されないということです。
[rust abi]: https://github.com/rust-lang/rfcs/issues/600
### PreservedレジスタとScratchレジスタ
呼び出し規約はレジスタを2種類に分けています<ruby>preserved<rp> (</rp><rt>保存</rt><rp>) </rp></ruby>レジスタと<ruby>scratch<rp> (</rp><rt>下書き</rt><rp>) </rp></ruby>レジスタです。
preservedレジスタの値は関数呼び出しの前後で変化してはいけません。ですので、呼び出された関数訳注callの受け身で"callee"と呼ばれます)は、リターンする前にその値をもとに戻す場合に限り、その値を上書きできます。そのため、これらのレジスタは<ruby>callee-saved<rp> (</rp><rt>呼び出し先によって保存される</rt><rp>) </rp></ruby>と呼ばれます。よくとられる方法は、関数の最初でそのレジスタをスタックに保存し、リターンする直前にその値をもとに戻すことです。
それとは対照的に、呼び出された関数はscratchレジスタを何の制限もなく上書きすることができます。呼び出し元の関数がscratchレジスタの値を関数呼び出しの前後で保存したいなら、関数呼び出しの前に自分でスタックにプッシュするなどしてバックアップしておいて、もとに戻す必要があります。なので、scratchレジスタは<ruby>caller-saved<rp> (</rp><rt>呼び出し元によって保存される</rt><rp>) </rp></ruby>です。
x86_64においては、C言語の呼び出し規約は以下のpreservedレジスタとscratchレジスタを指定します
preservedレジスタ | scratchレジスタ
--- | ---
`rbp`, `rbx`, `rsp`, `r12`, `r13`, `r14`, `r15` | `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11`
_callee-saved_ | _caller-saved_
コンパイラはこれらのルールを知っているので、それにしたがってコードを生成します。例えば、ほとんどの関数は`push rbp`から始まるのですが、これは`rbp`をスタックにバックアップしているのです(`rbp`はcallee-savedなレジスタであるため
### すべてのレジスタを保存する
関数呼び出しとは対象的に、例外は **どんな命令の最中にも** 起きる可能性があります。多くの場合、生成されたコードが例外を引き起こすのかどうかは、コンパイル時には見当も付きません。例えば、コンパイラはある命令がスタックオーバーフローやページフォルトを起こすのか知ることができません。
いつ例外が起きるのかわからない以上、レジスタを事前にバックアップしておくことは不可能です。つまり、caller-savedレジスタを利用する呼び出し規約は、例外ハンドラには使えないということです。代わりに、 **すべてのレジスタを** 保存する規約を使わないといけません。`x86-interrupt`呼び出し規約はそのような呼び出し規約なので、関数が戻るときにすべてのレジスタが元の値に戻されることを保証してくれるというわけです。
これは、関数の初めにすべてのレジスタがスタックに保存されるということを意味しないことに注意してください。その代わりに、コンパイラは関数によって上書きされてしまうレジスタのみをバックアップします。こうすれば、数個のレジスタしか使わない短い関数に対して、とても効率的なコードが生成できるでしょう。
### 割り込み時のスタックフレーム
通常の関数呼び出し(`call`命令を使いますにおいては、CPUは対象の関数にジャンプする前にリターンアドレスをプッシュします。関数がリターンするとき`ret`命令を使います、CPUはこのリターンアドレスをポップし、そこにジャンプします。そのため、通常の関数呼び出しの際のスタックフレームは以下のようになっています
![function stack frame](function-stack-frame.svg)
しかし、例外と割り込みハンドラについては、リターンアドレスをプッシュするだけではだめです。なぜなら、割り込みハンドラはしばしばスタックポインタや、CPUフラグなどが異なる状況で実行されるからです。ですので、代わりに、CPUは割り込みが起こると以下の手順を実行します。
1. **スタックポインタをアラインする**: 割り込みはあらゆる命令において発生しうるので、スタックポインタもあらゆる値を取る可能性があります。しかし、CPU命令のうちいくつか例えばSSE命令の一部などはスタックポインタが16バイトの倍数になっていることを要求するので、そうなるようにCPUは割り込みの直後にスタックポインタを<ruby>揃え<rp> (</rp><rt>アラインし</rt><rp>) </rp></ruby>ます。
2. (場合によっては)**スタックを変更する**: スタックの変更は、例えばCPU例外がユーザーモードのプログラムで起こった場合のような、CPUの特権レベルを変更するときに起こります。いわゆる<ruby>割り込みスタック表<rp> (</rp><rt>Interrupt Stack Table</rt><rp>) </rp></ruby>を使うことで、特定の割り込みに対しスタックを変更するよう設定することも可能です。割り込みスタック表については次の記事で説明します。
3. **古いスタックポインタをプッシュする**: CPUは、割り込みが発生した際のアラインされる前のスタックポインタレジスタ`rsp`)とスタックセグメントレジスタ(`ss`)の値をプッシュします。これにより、割り込みハンドラからリターンしてきたときにもとのスタックポインタを復元することが可能になります。
4. **`RFLAGS`レジスタをプッシュして更新する**: [`RFLAGS`]レジスタは状態や制御のための様々なビットを保持しています。割り込みに入るとき、CPUはビットのうちいくつかを変更し古い値をプッシュしておきます。
5. **命令ポインタをプッシュする**: 割り込みハンドラ関数にジャンプする前に、CPUは命令ポインタ`rip`)とコードセグメント(`cs`)をプッシュします。これは通常の関数呼び出しにおける戻り値のプッシュに対応します。
6. (例外によっては)**エラーコードをプッシュする**: ページフォルトのような特定の例外の場合、CPUはエラーコードをプッシュします。これは、例外の原因を説明するものです。
7. **割り込みハンドラを呼び出す**: CPUは割り込みハンドラ関数のアドレスと<ruby>セグメント記述子<rp> (</rp><rt>segment descriptor</rt><rp>) </rp></ruby>をIDTの対応するフィールドから読み出します。そして、この値を`rip`と`cs`レジスタに書き出してから、ハンドラを呼び出します。
[`RFLAGS`]: https://en.wikipedia.org/wiki/FLAGS_register
ですので、割り込み時のスタックフレーム (interrupt stack frame) は以下のようになります:
![interrupt stack frame](exception-stack-frame.svg)
`x86_64`クレートにおいては、割り込み時のスタックフレームは[`InterruptStackFrame`]構造体によって表現されます。これは割り込みハンドラに`&mut`として渡されるため、これを使うことで例外の原因に関して追加で情報を手に入れることができます。例外のすべてがエラーコードをプッシュするわけではないので、この構造体にはエラーコードのためのフィールドはありません。これらの例外は[`HandlerFuncWithErrCode`]という別の関数型を使いますが、これらは追加で`error_code`引数を持ちます。
[`InterruptStackFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptStackFrame.html
### 舞台裏では何が
`x86-interrupt`呼び出し規約は、この例外<ruby>処理<rp> (</rp><rt>ハンドル</rt><rp>) </rp></ruby>プロセスのややこしいところをほぼ全て隠蔽してくれる、強力な抽象化です。しかし、その後ろで何が起こっているのかを知っておいたほうが良いこともあるでしょう。以下に、`x86-interrupt`呼び出し規約がやってくれることを簡単なリストにして示しました。
- **引数を取得する**: 多くの呼び出し規約においては、引数はレジスタを使って渡されることを想定しています。例外ハンドラにおいては、スタックにバックアップする前にレジスタの値を上書きしてはいけないので、これは不可能です。その代わり、`x86-interrupt`呼び出し規約は、引数が既に特定のオフセットでスタック上にあることを認識しています。
- **`iretq`を使ってリターンする**: 割り込み時のスタックフレームは通常の関数呼び出しのスタックフレームとは全く異なるため、通常の `ret` 命令を使ってハンドラ関数から戻ることはできません。その代わりに、`iretq` 命令を使う必要があります。
- **エラーコードを処理する**: いくつかの例外の場合、エラーコードがプッシュされるのですが、これが状況をより複雑にします。エラーコードはスタックのアラインメントを変更し(次の箇条を参照)、リターンする前にスタックからポップされる必要があるのです。`x86-interrupt`呼び出し規約は、このややこしい仕組みをすべて処理してくれます。しかし、どのハンドラ関数がどの例外に使われているかは呼び出し規約側にはわからないので、関数の引数の数からその情報を推測する必要があります。つまり、プログラマはやはりそれぞれの例外に対して正しい関数型を使う責任があるということです。幸いにも、`x86_64`クレートで定義されている`InterruptDescriptorTable`型が、正しい関数型が確実に使われるようにしてくれます。
- **スタックをアラインする**: 一部の命令特にSSE命令には、16バイトのスタックアラインメントを必要とするものがあります。CPUは例外が発生したときには必ずこのようにスタックが<ruby>整列<rp> (</rp><rt>アライン</rt><rp>) </rp></ruby>されることを保証しますが、例外の中には、エラーコードをプッシュして再びスタックの整列を壊してしまうものもあります。この場合、`x86-interrupt`の呼び出し規約は、スタックを再整列させることでこの問題を解決します。
もしより詳しく知りたい場合は、例外の処理について[naked function][naked functions]を使って説明する一連の記事があります。[この記事の最下部][too-much-magic]にそこへのリンクがあります。
[naked functions]: https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md
[too-much-magic]: #sasuganijian-dan-sugi
## 実装
理屈は理解したので、私達のカーネルでCPUの例外を実際に処理していきましょう。まず、`src/interrupts.rs`に割り込みのための新しいモジュールを作ります。このモジュールはまず、`init_idt`関数という、新しい`InterruptDescriptorTable`を作る関数を定義します。
``` rust
// in src/lib.rs
pub mod interrupts;
// in src/interrupts.rs
use x86_64::structures::idt::InterruptDescriptorTable;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
}
```
これで、ハンドラ関数を追加していくことができます。まず、[ブレークポイント例外][breakpoint exception]のハンドラを追加するところから始めましょう。ブレークポイント例外は、例外処理のテストをするのにうってつけの例外なのです。この例外の唯一の目的は、ブレークポイント命令`int3`が実行された時、プログラムを一時停止させることです。
[breakpoint exception]: https://wiki.osdev.org/Exceptions#Breakpoint
ブレークポイント例外はよくデバッガによって使われます。ユーザーがブレークポイントを設定すると、デバッガが対応する命令を`int3`命令で置き換え、その行に到達したときにCPUがブレークポイント例外を投げるようにするのです。ユーザがプログラムを続行したい場合は、デバッガは`int3`命令をもとの命令に戻してプログラムを再開します。より詳しく知るには、[How debuggers work]["_How debuggers work_"]というシリーズ記事を読んでください。
["_How debuggers work_"]: https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
今回の場合、命令を上書きしたりする必要はありません。ブレークポイント命令が実行された時、メッセージを表示したうえで実行を継続したいだけです。ですので、単純な`breakpoint_handler`関数を作ってIDTに追加してみましょう。
```rust
// in src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
}
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame)
{
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
```
私達のハンドラは、ただメッセージを出力し、割り込みスタックフレームを整形して出力するだけです。
これをコンパイルしようとすると、以下のエラーが起こります:
```
error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180)
--> src/main.rs:53:1
|
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
54 | | println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
55 | | }
| |_^
|
= help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable
```
このエラーは、`x86-interrupt`呼び出し規約がまだ不安定なために発生します。これを強制的に使うためには、`lib.rs`の最初に`#![feature(abi_x86_interrupt)]`を追記して、この機能を明示的に有効化してやる必要があります。
### IDTを読み込む
CPUがこの割り込みディスクリプタテーブル(IDT)を使用するためには、[`lidt`]命令を使ってこれを読み込む必要があります。`x86_64`の`InterruptDescriptorTable`構造体には、そのための[`load`][InterruptDescriptorTable::load]というメソッド関数が用意されています。それを使ってみましょう:
[`lidt`]: https://www.felixcloutier.com/x86/lgdt:lidt
[InterruptDescriptorTable::load]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html#method.load
```rust
// in src/interrupts.rs
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.load();
}
```
これをコンパイルしようとすると、以下のエラーが発生します:
```
error: `idt` does not live long enough
--> src/interrupts/mod.rs:43:5
|
43 | idt.load();
| ^^^ does not live long enough
44 | }
| - borrowed value only lives until here
|
= note: borrowed value must be valid for the static lifetime...
```
`load`メソッドは(`idt`に)`&'static self`、つまりプログラムの実行されている間ずっと有効な参照を期待しています。これは、私達が別のIDTを読み込まない限り、CPUは割り込みのたびにこの表にアクセスするからです。そのため、`'static`より短いライフタイムの場合、<ruby>use-after-free<rp> (</rp><rt>メモリ解放後にアクセス</rt><rp>) </rp></ruby>バグが発生する可能性があります。
実際、これはまさにここで起こっていることです。私達の`idt`はスタック上に生成されるので、`init`関数の中でしか有効ではないのです。この関数が終わると、このスタックメモリは他の関数に使い回されるので、CPUはどこかもわからないスタックメモリをIDTとして解釈してしまうのです。幸運にも、`InterruptDescriptorTable::load`メソッドは関数定義にこのライフタイムの要件を組み込んでいるので、Rustコンパイラはこのバグをコンパイル時に未然に防ぐことができたというわけです。
この問題を解決するには、`idt`を`'static`なライフタイムの場所に格納する必要があります。これを達成するには、[`Box`]を使ってIDTをヒープに割当て、続いてそれを`'static`な参照に変換すればよいです。しかし、私達はOSのカーネルを書いている途中であり、まだヒープを持っていません。
[`Box`]: https://doc.rust-lang.org/std/boxed/struct.Box.html
別の方法として、IDTを`static`として保存してみましょう:
```rust
static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
```
しかし、問題が発生しますstaticは<ruby>不変<rp> (</rp><rt>イミュータブル</rt><rp>) </rp></ruby>なので、`init`関数でエントリを変更することができません。これは[`static mut`]を使って解決できそうです:
[`static mut`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
```rust
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
unsafe {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
}
```
このように変更するとエラーなくコンパイルできますが、このような書き方は全く慣用的ではありません。`static mut`はデータ競合を非常に起こしやすいので、アクセスするたびに[unsafeブロック][`unsafe` block]が必要になります。
[`unsafe` block]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers
#### Lazy Staticsにおまかせ
幸いにも、例の`lazy_static`マクロが存在します。このマクロは`static`をコンパイル時に評価する代わりに、最初に参照されたときに初期化を行います。このため、初期化時にはほとんどすべてのことができ、実行時にのみ決定する値を読み込むこともできます。
[VGAテキストバッファの抽象化をした][vga text buffer lazy static]ときに、すでに`lazy_static`クレートはインポートしました。そのため、すぐに`lazy_static!`マクロを使って静的なIDTを作ることができます。
[vga text buffer lazy static]: @/edition-2/posts/03-vga-text-buffer/index.ja.md#dai-keta-lazy-jing-de-bian-shu
```rust
// in src/interrupts.rs
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
```
この方法では`unsafe`ブロックが必要ないことに注目してください。`lazy_static!`マクロはその内部で`unsafe`を使ってはいるのですが、これは安全なインターフェースの中に抽象化されているのです。
### 実行する
カーネルで例外を動作させるための最後のステップは、`main.rs`から`init_idt`関数を呼び出すことです。直接呼び出す代わりに、より一般的な`init`関数を`lib.rs`に導入します:
```rust
// in src/lib.rs
pub fn init() {
interrupts::init_idt();
}
```
この関数により、`main.rs`、`lib.rs`および結合テストにおける、異なる`_start`関数で共有される、初期化ルーチンの「中央広場」ができました。
`main.rs`内の`_start`関数を更新して、`init`を呼び出し、そのあとブレークポイント例外を発生させるようにしてみましょう:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init(); // new
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3(); // new
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
`cargo run`を使ってQEMU内でこれを実行すると、以下のようになります
![QEMU printing `EXCEPTION: BREAKPOINT` and the interrupt stack frame](qemu-breakpoint-exception.png)
うまくいきましたCPUは私達のブレークポイントハンドラを呼び出すのに成功し、これがメッセージを出力し、そのあと`_start`関数に戻って、`It did not crash!`のメッセージを出力しました。
割り込みスタックフレームは、例外が発生した時の命令とスタックポインタを教えてくれることがわかります。これは、予期せぬ例外をデバッグする際に非常に便利です。
### テストを追加する
上記の動作が継続することを確認するテストを作成してみましょう。まず、`_start` 関数を更新して `init` を呼び出すようにします。
```rust
// in src/lib.rs
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init(); // new
test_main();
loop {}
}
```
Rustのテストでは、`main.rs`とは全く無関係に`lib.rs`をテストするので、この`_start`関数は`cargo test --lib`を実行する際に使用されることを思い出してください。テストを実行する前にIDTを設定するために、ここで`init`を呼び出す必要があります。
では、`test_breakpoint_exception`テストを作ってみましょう:
```rust
// in src/interrupts.rs
#[test_case]
fn test_breakpoint_exception() {
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3();
}
```
このテストでは、`int3`関数を呼び出してブレークポイント例外を発生させます。その後も実行が続くことを確認することで、ブレークポイントハンドラが正しく動作していることを保証します。
この新しいテストを試すには、`cargo test`(すべてのテストを試したい場合)または`cargo test --lib``lib.rs`とそのモジュールのテストのみの場合)を実行すればよいです。出力は以下のようになるはずです:
```
blog_os::interrupts::test_breakpoint_exception... [ok]
```
## さすがに簡単すぎ?
`x86-interrupt`呼び出し規約と[`InterruptDescriptorTable`]型のおかげで、例外処理のプロセスは比較的わかりやすく、面倒なところはありませんでした。「これではさすがに簡単すぎる、例外処理の闇をすべて学び尽くしたい」というあなた向けの記事もあります:私達の[Handling Exceptions with Naked Functions][“Handling Exceptions with Naked Functions”]シリーズ(未訳)では、`x86-interrupt`呼び出し規約を使わずに例外を処理する方法を学び、さらには独自のIDT型を定義します。`x86-interrupt`呼び出し規約や、`x86_64`クレートが存在する前は、これらの記事が主な例外処理に関する記事でした。なお、これらの記事はこのブログの[第1版][first edition]をもとにしているので、内容が古くなっている可能性があることに注意してください。
[“Handling Exceptions with Naked Functions”]: @/edition-1/extra/naked-exceptions/_index.md
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[first edition]: @/edition-1/_index.md
## 次は?
例外を捕捉し、そこから戻ってくることに成功しました!次のステップは、すべての例外を捕捉できるようにすることです。なぜなら、補足されなかった例外は致命的な[トリプルフォルト][triple fault]を引き起こし、これはシステムリセットにつながってしまうからです。次の記事では、[ダブルフォルト][double faults]を正しく捕捉することで、これを回避できることを説明します。
[triple fault]: https://wiki.osdev.org/Triple_Fault
[double faults]: https://wiki.osdev.org/Double_Fault#Double_Fault

View File

@@ -1,478 +0,0 @@
+++
title = "CPU 예외 (Exception)"
weight = 5
path = "ko/cpu-exceptions"
date = 2018-06-17
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "1c9b5edd6a5a667e282ca56d6103d3ff1fd7cfcb"
# GitHub usernames of the people that translated this post
translators = ["JOE1994"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["KimWang906"]
+++
CPU 예외 (exception)는 유효하지 않은 메모리 주소에 접근하거나 분모가 0인 나누기 연산을 하는 등 허용되지 않은 작업 실행 시 발생합니다. CPU 예외를 처리할 수 있으려면 예외 처리 함수 정보를 제공하는 _인터럽트 서술자 테이블 (interrupt descriptor table; IDT)_ 을 설정해 두어야 합니다. 이 글에서는 커널이 [breakpoint 예외][breakpoint exceptions]를 처리한 후 정상 실행을 재개할 수 있도록 구현할 것입니다.
[breakpoint exceptions]: https://wiki.osdev.org/Exceptions#Breakpoint
<!-- more -->
이 블로그는 [GitHub 저장소][GitHub]에서 오픈 소스로 개발되고 있으니, 문제나 문의사항이 있다면 저장소의 'Issue' 기능을 이용해 제보해주세요. [페이지 맨 아래][at the bottom]에 댓글을 남기실 수도 있습니다. 이 포스트와 관련된 모든 소스 코드는 저장소의 [`post-05 브랜치`][post branch]에서 확인하실 수 있습니다.
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-05
<!-- toc -->
## 개요
예외 (exception)는 현재 실행 중인 CPU 명령어에 문제가 있음을 알립니다. 예를 들면, 분모가 0인 나누기 연산을 CPU 명령어가 하려고 하면 CPU가 예외를 발생시킵니다. 예외가 발생하게 되면 CPU는 진행 중인 작업을 일시 중단한 후 즉시 예외 처리 함수 (exception handler)를 호출합니다 (발생한 예외의 종류에 따라 호출될 예외 처리 함수가 결정됩니다).
x86 아키텍처에는 20가지 정도의 CPU 예외가 존재합니다. 그 중 제일 중요한 것들은 아래와 같습니다:
- **페이지 폴트 (Page Fault)**: 접근이 허용되지 않은 메모리에 접근을 시도하는 경우 페이지 폴트가 발생하게 됩니다. 예를 들면, CPU가 실행하려는 명령어가 (1) 매핑되지 않은 페이지로부터 데이터를 읽어오려고 하거나, (2) 읽기 전용 페이지에 데이터를 쓰려고 하는 경우에 페이지 폴트가 발생합니다.
- **유효하지 않은 Opcode**: CPU에 주어진 명령어의 Opcode를 CPU가 지원하지 않을 때 발생합니다. 새로 출시된 [SSE 명령어][SSE instructions]를 구식 CPU에서 실행하려 하면 예외가 발생하게 됩니다.
- **General Protection Fault**: 이 예외는 가장 광범위한 원인을 가진 예외입니다. 사용자 레벨 코드에서 권한 수준이 높은 명령어 (privileged instruction)를 실행하거나 configuration 레지스터를 덮어 쓰는 등 다양한 접근 권한 위반 상황에 발생합니다.
- **더블 폴트 (Double Fault)**: 예외 발생 시 CPU는 알맞은 예외 처리 함수의 호출을 시도합니다. _예외 처리 함수를 호출하는 도중에_ 또 예외가 발생하는 경우, CPU는 더블 폴트 (double fault) 예외를 발생시킵니다. 또한 예외를 처리할 예외 처리 함수가 등록되지 않은 경우에도 더블 폴트 예외가 발생합니다.
- **트리플 폴트 (Triple Fault)** : CPU가 더블 폴트 예외 처리 함수를 호출하려고 하는 사이에 예외가 발생하는 경우, CPU는 치명적인 _트리플 폴트 (triple fault)_ 예외를 발생시킵니다. 트리플 폴트 예외를 처리하는 것은 불가능 하므로 대부분의 프로세서들은 트리플 폴트 발생 시 프로세서를 초기화하고 운영체제를 재부팅합니다.
[SSE instructions]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
모든 CPU 예외들의 목록을 보시려면 [OSDev wiki][exceptions]를 확인해주세요.
[exceptions]: https://wiki.osdev.org/Exceptions
### 인터럽트 서술사 테이블 (Interrupt Descriptor Table) {#the-interrupt-descriptor-table}
예외 발생을 포착하고 대응할 수 있으려면 _인터럽트 서술자 테이블 (Interrupt Descriptor Table; IDT)_ 이 필요합니다.
이 테이블을 통해 우리는 각각의 CPU 예외를 어떤 예외 처리 함수가 처리할지 지정합니다. 하드웨어에서 이 테이블을 직접 사용하므로 테이블의 형식은 정해진 표준에 따라야 합니다. 테이블의 각 엔트리는 아래와 같은 16 바이트 구조를 따릅니다:
| 타입 | 이름 | 설명 |
| ---- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
| u16 | Function Pointer [0:15] | 예외 처리 함수에 대한 64비트 포인터의 하위 16비트 |
| u16 | GDT selector | [전역 서술자 테이블 (global descriptor table)][global descriptor table]에서 코드 세그먼트를 선택하는 값 |
| u16 | Options | (표 아래의 설명 참조) |
| u16 | Function Pointer [16:31] | 예외 처리 함수에 대한 64비트 포인터의 2번째 하위 16비트 |
| u32 | Function Pointer [32:63] | 예외 처리 함수에 대한 64비트 포인터의 상위 32비트 |
| u32 | Reserved | 사용 보류 중인 영역 |
[global descriptor table]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
Options 필드는 아래의 형식을 갖습니다:
| 비트 구간 | 이름 | 설명 |
| --------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 0-2 | Interrupt Stack Table Index | 0: 스택을 교체하지 않는다, 1-7: 이 인터럽트 처리 함수가 호출된 경우 Interrupt Stack Table의 n번째 스택으로 교체한다. |
| 3-7 | Reserved | 사용 보류 중인 영역 |
| 8 | 0: Interrupt Gate, 1: Trap Gate | 비트가 0이면 이 예외 처리 함수가 호출 이후 인터럽트 발생 억제 |
| 9-11 | must be one | 각 비트는 언제나 1 |
| 12 | must be zero | 언제나 0 |
| 1314 | Descriptor Privilege Level (DPL) | 이 예외 처리 함수를 호출하는 데에 필요한 최소 특권 레벨 |
| 15 | Present |
각 예외마다 IDT에서의 인덱스가 배정되어 있습니다. invalid opcode 예외는 테이블 인덱스 6이 배정되어 있고, 페이지 폴트 예외는 테이블 인덱스 14가 배정되어 있습니다. 하드웨어는 미리 배정된 인덱스를 이용해 각 예외에 대응하는 IDT 엔트리를 자동으로 불러올 수 있습니다. OSDev 위키의 [Exception Table][exceptions]의 “Vector nr.”로 명명된 열을 보시면 모든 예외 및 배정된 인덱스를 확인하실 수 있습니다.
예외가 발생하면 CPU는 대략 아래의 작업들을 순서대로 진행합니다:
1. Instruction Pointer 레지스터와 [RFLAGS] 레지스터를 비롯해 몇몇 레지스터들의 값을 스택에 push (저장)합니다 (나중에 이 값들을 사용할 것입니다).
2. 발생한 예외의 엔트리를 인터럽트 서술사 테이블 (IDT)로부터 읽어옵니다. 예를 들면, 페이지 폴트 발생 시 CPU는 IDT의 14번째 엔트리를 읽어옵니다.
3. 등록된 엔트리가 없을 경우, 더블 폴트 예외를 발생시킵니다.
4. 해당 엔트리가 인터럽트 게이트인 경우 (40번 비트 = 0), 하드웨어 인터럽트 발생을 억제합니다.
5. 지정된 [GDT] 선택자를 CS 세그먼트로 읽어옵니다.
6. 지정된 예외 처리 함수로 점프합니다.
[RFLAGS]: https://en.wikipedia.org/wiki/FLAGS_register
[GDT]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
위의 4단계와 5단계가 잘 이해되지 않아도 걱정 마세요. 전역 서술자 테이블 (Global Descriptor Table; GDT)과 하드웨어 인터럽트는 이후에 다른 글에서 더 설명할 것입니다.
## IDT 타입
IDT를 나타내는 타입을 직접 구현하지 않고 `x86_64` 크레이트의 [`InterruptDescriptorTable` 구조체][`InterruptDescriptorTable` struct] 타입을 사용합니다:
[`InterruptDescriptorTable` struct]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
``` rust
#[repr(C)]
pub struct InterruptDescriptorTable {
pub divide_by_zero: Entry<HandlerFunc>,
pub debug: Entry<HandlerFunc>,
pub non_maskable_interrupt: Entry<HandlerFunc>,
pub breakpoint: Entry<HandlerFunc>,
pub overflow: Entry<HandlerFunc>,
pub bound_range_exceeded: Entry<HandlerFunc>,
pub invalid_opcode: Entry<HandlerFunc>,
pub device_not_available: Entry<HandlerFunc>,
pub double_fault: Entry<HandlerFuncWithErrCode>,
pub invalid_tss: Entry<HandlerFuncWithErrCode>,
pub segment_not_present: Entry<HandlerFuncWithErrCode>,
pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
pub page_fault: Entry<PageFaultHandlerFunc>,
pub x87_floating_point: Entry<HandlerFunc>,
pub alignment_check: Entry<HandlerFuncWithErrCode>,
pub machine_check: Entry<HandlerFunc>,
pub simd_floating_point: Entry<HandlerFunc>,
pub virtualization: Entry<HandlerFunc>,
pub security_exception: Entry<HandlerFuncWithErrCode>,
// 일부 필드는 생략했습니다
}
```
구조체의 각 필드는 IDT의 엔트리를 나타내는 `idt::Entry<F>` 타입을 가집니다. 타입 인자 `F`는 사용될 예외 처리 함수의 타입을 정의합니다. 어떤 엔트리는 `F`에 [`HandlerFunc`]를 또는 `F`에 [`HandlerFuncWithErrCode`]를 필요로 하며 페이지 폴트는 [`PageFaultHandlerFunc`]를 필요로 합니다.
[`idt::Entry<F>`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.Entry.html
[`HandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFunc.html
[`HandlerFuncWithErrCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFuncWithErrCode.html
[`PageFaultHandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.PageFaultHandlerFunc.html
`HandlerFunc` 타입을 먼저 살펴보겠습니다:
```rust
type HandlerFunc = extern "x86-interrupt" fn(_: InterruptStackFrame);
```
`HandlerFunc`는 함수 타입 `extern "x86-interrupt" fn`의 [타입 별칭][type alias]입니다. `extern` 키워드는 [외부 함수 호출 규약 (foreign calling convention)][foreign calling convention]을 사용하는 함수를 정의할 때 쓰이는데, 주로 C 함수와 상호작용하는 경우에 쓰입니다 (`extern "C" fn`). `x86-interrupt` 함수 호출 규약은 무엇일까요?
[type alias]: https://doc.rust-lang.org/book/ch19-04-advanced-types.html#creating-type-synonyms-with-type-aliases
[foreign calling convention]: https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions
## 인터럽트 호출 규약
예외는 함수 호출과 유사한 점이 많습니다: 호출된 함수의 첫 명령어로 CPU가 점프한 후 함수 안의 명령어들을 차례대로 실행합니다. 그 후 CPU가 반환 주소로 점프하고, 기존에 실행 중이었던 함수의 실행을 이어갑니다.
하지만 예외와 함수 호출 사이에 중요한 차이점이 있습니다: 일반 함수의 경우 컴파일러가 삽입한 `call` 명령어를 통해 호출하지만, 예외는 _어떤 명령어 실행 도중에라도_ 발생할 수 있습니다. 이 차이점의 중대성을 이해하려면 함수 호출 과정을 더 면밀히 살펴봐야 합니다.
[함수 호출 규약][Calling conventions]은 함수 호출 과정의 세부 사항들을 규정합니다. 예를 들면, 함수 인자들이 어디에 저장되는지 (레지스터 또는 스택), 함수의 반환 값을 어떻게 전달할지 등을 정합니다. x86_64 리눅스에서 C 함수 호출 시 [System V ABI]가 규정하는 아래의 규칙들이 적용됩니다:
[Calling conventions]: https://en.wikipedia.org/wiki/Calling_convention
[System V ABI]: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
- 함수의 첫 여섯 인자들은 `rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9` 레지스터에 저장합니다
- 7번째 함수 인자부터는 모두 스택에 저장합니다
- 함수의 반환 값은 `rax`와 `rdx` 레지스터에 저장됩니다
참고로 Rust는 C ABI를 따르지 않기에 (사실, [Rust는 규정된 ABI가 존재하지 않습니다][rust abi]), 이 법칙들은 `extern "C" fn`으로 정의된 함수들에만 적용됩니다.
[rust abi]: https://github.com/rust-lang/rfcs/issues/600
### Preserved 레지스터와 Scratch 레지스터
함수 호출 규약은 레지스터들을 크게 두 가지 (_preserved_ 레지스터와 _scratch_ 레지스터)로 분류합니다.
_preserved_ 레지스터들의 값은 함수 호출 전/후에 보존되어야 합니다. 호출된 함수 (callee)가 이 레지스터들에 다른 값을 저장해 사용하려면 반환 직전에 이 레지스터들에 원래 저장되어 있던 값을 복원해 놓아야 합니다. preserved 레지스터는 _“callee-saved”_ 레지스터라고도 불립니다. 함수 실행 시작 시 이 레지스터들의 값들을 스택에 저장했다가 함수 반환 직전에 복구하는 것이 일반적입니다.
반면, 호출된 함수가 _scratch_ 레지스터들의 값을 자유롭게 덮어 쓰는 것은 괜찮습니다. 함수 호출 전/후로 scratch 레지스터의 값을 보존하고 싶다면, 호출하는 측 (caller)이 함수 호출 전에 레지스터의 값을 스택에 저장해뒀다가 함수의 실행이 끝난 후 레지스터의 값을 본래 값으로 복원해야 합니다. scratch 레지스터는 _“caller-saved”_ 레지스터라고도 불립니다.
x86_64에서는 C 함수 호출 규약이 preserved 레지스터와 scratch 레지스터를 아래와 같이 정합니다:
| preserved 레지스터 | scratch 레지스터 |
| ----------------------------------------------- | ----------------------------------------------------------- |
| `rbp`, `rbx`, `rsp`, `r12`, `r13`, `r14`, `r15` | `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11` |
| _callee-saved_ | _caller-saved_ |
컴파일러는 이 규칙들에 따라 코드를 컴파일 합니다. 예를 들면 대부분의 함수들은 `push rbp` 로 시작하는데, 이는 callee-saved 레지스터인 `rbp`를 스택에 저장합니다.
### 모든 레지스터들의 값 보존하기
함수 호출과 달리 예외는 _어떤_ 명령어가 실행 중이든 관계 없이 발생할 수 있습니다. 대체로 컴파일 시간에는 컴파일 결과 생성된 코드가 예외를 발생시킬지의 유무를 장담하기 어렵습니다. 예를 들어, 컴파일러는 임의의 명령어가 스택 오버플로우 또는 페이지 폴트를 일으킬지 판별하기 어렵습니다.
예외가 언제 발생할지 알 수 없다보니 레지스터에 저장된 값들을 미리 백업해놓을 수가 없습니다. 즉, 예외 처리 함수 구현 시 caller-saved 레지스터에 의존하는 함수 호출 규약을 사용할 수가 없습니다. 예외 처리 함수 구현 시 _모든 레지스터_ 들의 값을 보존하는 함수 호출 규약을 사용해야 합니다. 예시로 `x86-interrupt` 함수 호출 규약은 함수 반환 시 모든 레지스터들의 값이 함수 호출 이전과 동일하게 복원되도록 보장합니다.
함수 실행 시작 시 모든 레지스터들의 값이 스택에 저장된다는 뜻은 아닙니다. 호출된 함수가 덮어 쓸 레지스터들만을 컴파일러가 스택에 백업합니다. 이렇게 하면 적은 수의 레지스터를 사용하는 함수를 컴파일 할 때 짧고 효율적인 코드를 생성할 수 있습니다.
### 인터럽트 스택 프레임 {# the-interrupt-stack-frame}
일반적인 함수 호출 시 (`call` 명령어 이용), CPU는 호출된 함수로 제어 흐름을 넘기기 전에 반환 주소를 스택에 push (저장)합니다. 함수 반환 시 (`ret` 명령어 이용), CPU는 스택에 저장해뒀던 반환 주소를 읽어온 후 해당 주소로 점프합니다. 일반적인 함수 호출 시 스택 프레임의 모습은 아래와 같습니다:
![function stack frame](function-stack-frame.svg)
예외 및 인터럽트 처리 함수의 경우, 일반 함수가 실행되는 CPU 컨텍스트 (스택 포인터, CPU 플래그 등)가 아닌 별개의 CPU 컨텍스트에서 실행됩니다. 따라서 단순히 스택에 반환 주소를 push하는 것보다 더 복잡한 사전 처리가 필요합니다. 인터럽트 발생 시 CPU가 아래의 작업들을 처리합니다.
1. **스택 포인터 정렬**: 인터럽트는 어느 명령어의 실행 중에도 발생할 수 있고, 따라서 스택 포인터 또한 임의의 값을 가질 수 있습니다. 하지만 특정 CPU 명령어들 (예: 일부 SSE 명령어)은 스택 포인터가 16바이트 단위 경계에 정렬되어 있기를 요구합니다. 따라서 CPU는 인터럽트 발생 직후에 스택 포인터를 알맞게 정렬합니다.
2. **스택 교체** (경우에 따라서): CPU의 특권 레벨 (privilege level)이 바뀌는 경우에 스택 교체가 일어납니다 (예: 사용자 모드 프로그램에서 CPU 예외가 발생할 때). 또한 _인터럽트 스택 테이블 (Interrupt Stack Table)_ 을 이용해 특정 인터럽트 발생 시 스택 교체가 이뤄지도록 설정하는 것 또한 가능합니다 (이후 다른 글에서 설명할 내용입니다).
3. **이전의 스택 포인터 push**: 인터럽트 발생 시, CPU는 스택 포인터를 정렬하기에 앞서 스택 포인터 (`rsp`)와 스택 세그먼트 (`ss`) 레지스터들을 저장 (push)합니다. 이로써 인터럽트 처리 함수로부터 반환 시 이전의 스택 포인터를 복원할 수 있습니다.
4. **`RFLAGS` 레지스터 push 및 수정**: [`RFLAGS`] 레지스터는 CPU의 다양한 제어 및 상태 비트들을 저장합니다. 인터럽트 발생 시 CPU는 기존 값을 push한 후 일부 비트들의 값을 변경합니다.
5. **instruction pointer push**: 인터럽트 처리 함수로 점프하기 전에, CPU는 instruction pointer (`rip`)와 code segment (`cs`) 레지스터들을 push합니다. 이는 일반 함수 호출 시 반환 주소를 push하는 것과 유사합니다.
6. **오류 코드 push** (일부 예외만 해당): 페이지 폴트 같은 일부 예외의 경우, CPU는 예외의 원인을 설명하는 오류 코드를 push합니다.
7. **인터럽트 처리 함수 호출**: CPU는 IDT로부터 인터럽트 처리 함수의 주소와 세그먼트 서술자 (segment descriptor)를 읽어옵니다. 읽어온 값들을 각각 `rip` 레지스터와 `cs` 레지스터에 저장함으로써 인터럽트 처리 함수를 호출합니다.
[`RFLAGS`]: https://en.wikipedia.org/wiki/FLAGS_register
_인터럽트 스택 프레임_ 은 아래와 같은 모습을 가집니다:
![interrupt stack frame](exception-stack-frame.svg)
`x86_64` 크레이트에서는 [`InterruptStackFrame`] 구조체 타입을 통해 인터럽트 스택 프레임을 구현합니다. 예외 처리 함수들은 `&mut InterruptStackFrame`를 인자로 받아서 예외 발생 원인에 대한 추가 정보를 얻을 수 있습니다. 이 구조체는 오류 코드를 저장하는 필드를 갖고 있지 않은데, 그 이유는 아주 일부의 예외들만이 오류 코드를 반환하기 때문입니다. 오류 코드를 반환하는 예외들은 [`HandlerFuncWithErrCode`] 함수 타입을 사용하는데, 이 함수 타입은 추가적으로 `error_code` 인자를 받습니다.
[`InterruptStackFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptStackFrame.html
### 무대 뒤의 상황
함수 호출 규약 `x86-interrupt`는 예외 처리 과정의 세부적인 사항들을 대부분 숨겨주는 강력한 추상화 계층입니다. 하지만 때로는 추상화 계층 안에서 무슨 일이 일어나는지 알아두는 것이 도움이 됩니다. 아래는 함수 호출 규약 `x86-interrupt`가 처리하는 작업들의 개요입니다.
- **함수 인자 읽어오기**: 대부분의 함수 호출 규약들은 함수 인자들이 레지스터를 통해 전달되는 것으로 생각합니다. 그러나 예외 처리 함수는 그렇게 할 수가 없습니다. 그 이유는 스택에 레지스터들의 값들을 백업하기 전에는 어떤 레지스터도 덮어 쓸 수 없기 때문입니다. 함수 호출 규약 `x86-interrupt`는 함수 인자들이 레지스터가 아니라 스택의 특정 위치에 저장되어 있다고 가정합니다.
- **`iretq`를 통해 반환**: 인터럽트 스택 프레임은 일반 함수 호출 시 사용되는 스택 프레임과는 완전히 별개의 것이라서 `ret` 명령어를 사용해서는 인터럽트 처리 함수로부터 제대로 반환할 수 없습니다. 대신 `iretq` 명령어를 사용하여 반환합니다.
- **오류 코드 처리**: 일부 예외에 한해 push되는 오류 코드는 일을 번거롭게 합니다. 이 오류 코드로 인해 스택 정렬이 망가뜨려지며 (아래 '스택 정렬' 항목 참고), 예외 처리 함수로부터 반환하기 전에 오류 코드를 스택으로부터 pop (제거)해야 합니다. 함수 호출 규약 `x86-interrupt`가 오류 코드로 인한 번거로움을 대신 감당해줍니다. `x86-interrupt`는 어떤 예외 처리 함수가 어떤 예외에 대응하는지 알지 못하기에, 함수의 인자 개수를 통해 해당 정보를 유추합니다. 따라서 개발자는 오류 코드가 push되는 예외와 그렇지 않은 예외에 대해 각각 정확한 함수 타입을 사용해야만 합니다. 다행히 `x86_64` 크레이트가 제공하는 `InterruptDescriptorTable` 타입이 각 경우에 정확한 함수 타입이 사용되도록 보장합니다.
- **스택 정렬**: 일부 명령어들 (특히 SSE 명령어)은 스택이 16 바이트 경계에 정렬되어 있기를 요구합니다. 예외 발생 시 CPU는 해당 정렬이 맞춰져 있도록 보장하지만, 일부 예외의 경우에는 오류 코드를 push하면서 맞춰져 있던 정렬을 망가뜨립니다. 함수 호출 규약 `x86-interrupt`는 해당 상황에서 망가진 정렬을 다시 맞춰줍니다.
더 자세한 내용이 궁금하시다면, [naked 함수][naked functions]를 사용한 예외 처리 과정을 설명하는 저희 블로그의 또다른 글 시리즈를 참고하세요 (링크는 [이 글의 맨 마지막][too-much-magic]을 참조).
[naked functions]: https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md
[too-much-magic]: #too-much-magic
## 구현
이론적 배경 설명은 끝났고, 이제 CPU 예외 처리 기능을 커널에 구현해보겠습니다. 새로운 모듈 `interrupts`를 `src/interrupts.rs`에 만든 후, 새로운 `InterruptDescriptorTable`을 생성하는 함수 `init_idt`를 작성합니다.
``` rust
// in src/lib.rs
pub mod interrupts;
// in src/interrupts.rs
use x86_64::structures::idt::InterruptDescriptorTable;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
}
```
이제 예외 처리 함수들을 추가할 수 있습니다. [breakpoint 예외][breakpoint exception]를 위한 예외 처리 함수부터 작성해보겠습니다. breakpoint 예외는 예외 처리를 테스트하는 용도에 안성맞춤입니다. breakpoint 예외의 유일한 용도는 breakpoint 명령어 `int3`가 실행되었을 때 실행 중인 프로그램을 잠시 멈추는 것입니다.
[breakpoint exception]: https://wiki.osdev.org/Exceptions#Breakpoint
breakpoint 예외는 디버거 (debugger)에서 자주 사용됩니다: 사용자가 breakpoint를 설정하면 디버거는 breakpoint에 대응되는 명령어를 `int3` 명령어로 치환하는데, 이로써 해당 명령어에 도달했을 때 CPU가 breakpoint 예외를 발생시킵니다. 사용자가 프로그램 실행을 재개하면 디버거는 `int3` 명령어를 원래의 명령어로 다시 교체한 후 프로그램 실행을 재개합니다. 더 자세한 내용이 궁금하시면 ["_How debuggers work_"] 시리즈를 읽어보세요.
["_How debuggers work_"]: https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
지금 우리가 breakpoint 예외를 사용하는 상황에서는 명령어를 덮어쓸 필요가 전혀 없습니다. 우리는 breakpoint 예외가 발생했을 때 그저 메시지를 출력한 후 프로그램 실행을 재개하기만 하면 됩니다. 간단한 예외 처리 함수 `breakpoint_handler`를 만들고 IDT에 추가합니다:
```rust
// in src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
}
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame)
{
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
```
이 예외 처리 함수는 간단한 메시지와 함께 인터럽트 스택 프레임의 정보를 출력합니다.
컴파일을 시도하면 아래와 같은 오류 메시지가 출력됩니다:
```
error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180)
--> src/main.rs:53:1
|
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
54 | | println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
55 | | }
| |_^
|
= help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable
```
이 오류는 함수 호출 규약 `x86-interrupt`가 아직 unstable 하여 발생합니다. `lib.rs`의 맨 위에 `#![feature(abi_x86_interrupt)]` 속성을 추가하여 함수 호출 규약 `x86-interrupt`의 사용을 강제합니다.
### IDT 불러오기
우리가 만든 인터럽트 서술사 테이블을 CPU가 사용하도록 하려면, 먼저 [`lidt`] 명령어를 통해 해당 테이블을 불러와야 합니다. `x86_64` 크레이트가 제공하는 `InterruptDescriptorTable` 구조체의 함수 [`load`][InterruptDescriptorTable::load]를 통해 테이블을 불러옵니다:
[`lidt`]: https://www.felixcloutier.com/x86/lgdt:lidt
[InterruptDescriptorTable::load]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html#method.load
```rust
// in src/interrupts.rs
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.load();
}
```
컴파일 시 아래와 같은 오류가 발생합니다:
```
error: `idt` does not live long enough
--> src/interrupts/mod.rs:43:5
|
43 | idt.load();
| ^^^ does not live long enough
44 | }
| - borrowed value only lives until here
|
= note: borrowed value must be valid for the static lifetime...
```
`load` 함수는 `&'static self` 타입의 인자를 받는데, 이 타입은 프로그램 실행 시간 전체 동안 유효한 레퍼런스 타입입니다. 우리가 새로운 IDT를 로드하지 않는 이상 프로그램 실행 중 인터럽트가 발생할 때마다 CPU가 이 테이블에 접근할 것이기에, `'static` 라이프타임보다 짧은 라이프타임을 사용하면 use-after-free 버그가 발생할 수 있습니다.
`idt`는 스택에 생성되어 `init` 함수 안에서만 유효합니다. `init` 함수를 벗어나면 해당 스택 메모리는 다른 함수에 의해 재사용되므로 해당 메모리를 IDT로서 간주하고 참조한다면 임의의 함수의 스택 메모리로부터 데이터를 읽어오게 됩니다.
다행히 `InterruptDescriptorTable::load` 함수 정의에 라이프타임 요구 사항이 포함되어 있어 Rust 컴파일러가 잠재적인 use-after-free 버그를 컴파일 도중에 막아줍니다.
이 문제를 해결하려면 `idt`를 `'static` 라이프타임을 갖는 곳에 저장해야 합니다. [`Box`]를 통해 IDT를 힙 (heap) 메모리에 할당한 뒤 Box 에 저장된 IDT에 대한 `'static` 레퍼런스를 얻는 것은 해결책이 되지 못합니다. 그 이유는 아직 우리가 커널에 힙 메모리를 구현하지 않았기 때문입니다.
[`Box`]: https://doc.rust-lang.org/std/boxed/struct.Box.html
대안으로 `IDT`를 `static` 변수에 저장하는 것을 시도해보겠습니다:
```rust
static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
```
문제는 static 변수의 값은 변경할 수가 없어서, `init` 함수 실행 시 breakpoint 예외에 대응하는 IDT 엔트리를 수정할 수 없습니다.
대신 `IDT`를 [`static mut`] 변수에 저장해보겠습니다:
[`static mut`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
```rust
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
unsafe {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
}
```
이제 컴파일 오류가 발생하지는 않지만, Rust에서 `static mut`의 사용은 권장되지 않습니다. `static mut`는 데이터 레이스 (data race)를 일으키기 쉽기에, `static mut` 변수에 접근할 때마다 [`unsafe` 블록][`unsafe` block]을 반드시 사용해야 합니다.
[`unsafe` block]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers
#### 초기화 지연이 가능한 Static 변수 (Lazy Statics)
다행히 `lazy_static` 매크로를 사용하면 `static` 변수의 초기화를 컴파일 도중이 아니라 프로그램 실행 중 해당 변수가 처음 읽어지는 시점에 일어나게 할 수 있습니다. 따라서 프로그램 실행 시간에 다른 변수의 값을 읽어오는 등 거의 모든 작업을 변수 초기화 블록 안에서 제약 없이 진행할 수 있습니다.
이전에 [VGA 텍스트 버퍼에 대한 추상 인터페이스][vga text buffer lazy static]를 구현 시 의존 크레이트 목록에 `lazy_static`을 이미 추가했습니다. `lazy_static!` 매크로를 바로 사용하여 static 타입의 IDT를 생성합니다:
[vga text buffer lazy static]: @/edition-2/posts/03-vga-text-buffer/index.md#lazy-statics
```rust
// in src/interrupts.rs
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
```
이 코드에서는 `unsafe` 블록이 필요하지 않습니다. `lazy_static!` 매크로의 내부 구현에서는 `unsafe`가 사용되지만, 안전한 추상 인터페이스 덕분에 `unsafe`가 외부로 드러나지 않습니다.
### 실행하기
마지막으로 `main.rs`에서 `init_idt` 함수를 호출하면 커널에서 예외 발생 및 처리가 제대로 작동합니다.
직접 `init_idt` 함수를 호출하는 대신 범용 초기화 함수 `init`을 `lib.rs`에 추가합니다:
```rust
// in src/lib.rs
pub fn init() {
interrupts::init_idt();
}
```
`main.rs`와 `lib.rs` 및 통합 테스트들의 `_start` 함수들에서 공용으로 사용하는 초기화 루틴들의 호출은 앞으로 이 `init` 함수에 한데 모아 관리할 것입니다.
`main.rs`의 `_start_` 함수가 `init` 함수를 호출한 후 breakpoint exception을 발생시키도록 코드를 추가합니다:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init(); // 새로 추가한 코드
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3(); // 새로 추가한 코드
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
`cargo run`을 통해 QEMU에서 커널을 실행하면 아래의 출력 내용을 얻습니다:
![QEMU printing `EXCEPTION: BREAKPOINT` and the interrupt stack frame](qemu-breakpoint-exception.png)
성공입니다! CPU가 성공적으로 예외 처리 함수 `breakpoint_handler`를 호출했고, 예외 처리 함수가 메시지를 출력했으며, 그 후 `_start` 함수로 제어 흐름이 돌아와 `It did not crash!` 메시지도 출력됐습니다.
예외가 발생한 시점의 명령어 및 스택 포인터들을 인터럽트 스택 프레임이 알려줍니다. 이 정보는 예상치 못한 예외를 디버깅할 때 매우 유용합니다.
### 테스트 추가하기
위에서 확인한 동작을 위한 테스트를 작성해봅시다. 우선 `_start` 함수가 `init` 함수를 호출하도록 수정합니다:
```rust
// in src/lib.rs
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init(); // 새로 추가한 코드
test_main();
loop {}
}
```
Rust는 `lib.rs`를 `main.rs`와는 독립적으로 테스트하기 때문에 이 `_start` 함수는 `cargo test --lib` 실행 시에만 사용된다는 것을 기억하세요. 테스트 실행 전에 `init` 함수를 먼저 호출하여 IDT를 만들고 테스트 실행 시 사용되도록 설정합니다.
이제 `test_breakpoint_exception` 테스트를 생성할 수 있습니다:
```rust
// in src/interrupts.rs
#[test_case]
fn test_breakpoint_exception() {
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3();
}
```
테스트는 `int3` 함수를 호출하여 breakpoint 예외를 발생시킵니다. 예외 처리 후, 이전에 실행 중이었던 프로그램의 실행이 재개함을 확인함으로써 breakpoint handler가 제대로 작동하는지 점검합니다.
`cargo test` (모든 테스트 실행) 혹은 `cargo test --lib` (`lib.rs` 및 그 하위 모듈의 테스트만 실행) 커맨드를 통해 이 새로운 테스트를 실행해보세요. 테스트 실행 결과가 아래처럼 출력될 것입니다:
```
blog_os::interrupts::test_breakpoint_exception... [ok]
```
## 더 자세히 파헤치고 싶은 분들께 {#too-much-magic}
`x86-interrupt` 함수 호출 규약과 [`InterruptDescriptorTable`] 타입 덕분에 비교적 쉽게 예외 처리를 구현할 수 있었습니다. 예외 처리 시 우리가 이용한 추상화 단계 아래에서 일어나는 일들을 자세히 알고 싶으신가요? 그런 분들을 위해 준비했습니다: 저희 블로그의 또다른 글 시리즈 [“Handling Exceptions with Naked Functions”]는 `x86-interrupt` 함수 호출 규약 없이 예외 처리를 구현하는 과정을 다루며, IDT 타입을 직접 구현하여 사용합니다. 해당 글 시리즈는 `x86-interrupt` 함수 호출 규약 및 `x86_64` 크레이트가 생기기 이전에 작성되었습니다. 해당 시리즈는 이 블로그의 [첫 번째 버전][first edition]에 기반하여 작성되었기에 오래되어 더 이상 유효하지 않은 정보가 포함되어 있을 수 있으니 참고 부탁드립니다.
[“Handling Exceptions with Naked Functions”]: @/edition-1/extra/naked-exceptions/_index.md
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[first edition]: @/edition-1/_index.md
## 다음 단계는 무엇일까요?
이번 포스트에서 예외 (exception)를 발생시키고 처리한 후 예외로부터 반환하는 것까지 성공했습니다. 다음 단계는 우리의 커널이 모든 예외를 처리할 수 있게 하는 것입니다. 제대로 처리되지 않은 예외는 치명적인 [트리플 폴트 (triple fault)][triple fault]를 발생시켜 시스템이 리셋하도록 만듭니다. 다음 포스트에서는 트리플 폴트가 발생하지 않도록 [더블 폴트 (double fault)][double faults]를 처리하는 방법을 다뤄보겠습니다.
[triple fault]: https://wiki.osdev.org/Triple_Fault
[double faults]: https://wiki.osdev.org/Double_Fault#Double_Fault

View File

@@ -1,473 +0,0 @@
+++
title = "CPU异常处理"
weight = 5
path = "zh-CN/cpu-exceptions"
date = 2018-06-17
[extra]
# Please update this when updating the translation
translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"
# GitHub usernames of the people that translated this post
translators = ["liuyuran"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["JiangengDong", "Byacrya"]
+++
CPU异常在很多情况下都有可能发生比如访问无效的内存地址或者在除法运算里除以0。为了处理这些错误我们需要设置一个 _中断描述符表_ 来提供异常处理函数。在文章的最后,我们的内核将能够捕获 [断点异常][breakpoint exceptions] 并在处理后恢复正常执行。
[breakpoint exceptions]: https://wiki.osdev.org/Exceptions#Breakpoint
<!-- more -->
这个系列的blog在[GitHub]上开放开发如果你有任何问题请在这里开一个issue来讨论。当然你也可以在[底部][at the bottom]留言。你可以在[`post-05`][post branch]找到这篇文章的完整源码。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-05
<!-- toc -->
## 简述
异常信号会在当前指令触发错误时被触发例如执行了除数为0的除法。当异常发生后CPU会中断当前的工作并立即根据异常类型调用对应的错误处理函数。
在x86架构中存在20种不同的CPU异常类型以下为最重要的几种
- **Page Fault**: 页错误是被非法内存访问触发的,例如当前指令试图访问未被映射过的页,或者试图写入只读页。
- **Invalid Opcode**: 该错误是说当前指令操作符无效比如在不支持SSE的旧式CPU上执行了 [SSE 指令][SSE instructions]。
- **General Protection Fault**: 该错误的原因有很多,主要原因就是权限异常,即试图使用用户态代码执行核心指令,或是修改配置寄存器的保留字段。
- **Double Fault**: 当错误发生时CPU会尝试调用错误处理函数但如果 _在调用错误处理函数过程中_ 再次发生错误CPU就会触发该错误。另外如果没有注册错误处理函数也会触发该错误。
- **Triple Fault**: 如果CPU调用了对应 `Double Fault` 异常的处理函数依然没有成功,该错误会被抛出。这是一个致命级别的 _三重异常_,这意味着我们已经无法捕捉它,对于大多数操作系统而言,此时就应该重置数据并重启操作系统。
[SSE instructions]: https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions
在 [OSDev wiki][exceptions] 可以看到完整的异常类型列表。
[exceptions]: https://wiki.osdev.org/Exceptions
### 中断描述符表
要捕捉CPU异常我们需要设置一个 _中断描述符表_ (_Interrupt Descriptor Table_, IDT)用来捕获每一个异常。由于硬件层面会不加验证的直接使用所以我们需要根据预定义格式直接写入数据。符表的每一行都遵循如下的16字节结构。
| Type | Name | Description |
| ---- | ------------------------ | ------------------------------------------------------- |
| u16 | Function Pointer [0:15] | 处理函数地址的低位最后16位 |
| u16 | GDT selector | [全局描述符表][global descriptor table]中的代码段标记。 |
| u16 | Options | (如下所述) |
| u16 | Function Pointer [16:31] | 处理函数地址的中位中间16位 |
| u32 | Function Pointer [32:63] | 处理函数地址的高位(剩下的所有位) |
| u32 | Reserved |
[global descriptor table]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
Options字段的格式如下
| Bits | Name | Description |
| ----- | -------------------------------- | --------------------------------------------------------------- |
| 0-2 | Interrupt Stack Table Index | 0: 不要切换栈, 1-7: 当处理函数被调用时切换到中断栈表的第n层。 |
| 3-7 | Reserved |
| 8 | 0: Interrupt Gate, 1: Trap Gate | 如果该比特被置为0当处理函数被调用时中断会被禁用。 |
| 9-11 | must be one |
| 12 | must be zero |
| 1314 | Descriptor Privilege Level (DPL) | 执行处理函数所需的最小特权等级。 |
| 15 | Present |
每个异常都具有一个预定义的IDT序号比如 invalid opcode 异常对应6号而 page fault 异常对应14号因此硬件可以直接寻找到对应的IDT条目。 OSDev wiki中的 [异常对照表][exceptions] 可以查到所有异常的IDT序号在Vector nr.列)。
通常而言当异常发生时CPU会执行如下步骤
1. 将一些寄存器数据入栈,包括指令指针以及 [RFLAGS] 寄存器。(我们会在文章稍后些的地方用到这些数据。)
2. 读取中断描述符表IDT的对应条目比如当发生 page fault 异常时调用14号条目。
3. 判断该条目确实存在,如果不存在,则触发 double fault 异常。
4. 如果该条目属于中断门interrupt gatebit 40 被设置为0则禁用硬件中断。
5. 将 [GDT] 选择器载入代码段寄存器CS segment
6. 跳转执行处理函数。
[RFLAGS]: https://en.wikipedia.org/wiki/FLAGS_register
[GDT]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
不过现在我们不必为4和5多加纠结未来我们会单独讲解全局描述符表和硬件中断的。
## IDT类型
与其创建我们自己的IDT类型映射不如直接使用 `x86_64` crate 内置的 [`InterruptDescriptorTable` 结构][`InterruptDescriptorTable` struct],其实现是这样的:
[`InterruptDescriptorTable` struct]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
``` rust
#[repr(C)]
pub struct InterruptDescriptorTable {
pub divide_by_zero: Entry<HandlerFunc>,
pub debug: Entry<HandlerFunc>,
pub non_maskable_interrupt: Entry<HandlerFunc>,
pub breakpoint: Entry<HandlerFunc>,
pub overflow: Entry<HandlerFunc>,
pub bound_range_exceeded: Entry<HandlerFunc>,
pub invalid_opcode: Entry<HandlerFunc>,
pub device_not_available: Entry<HandlerFunc>,
pub double_fault: Entry<HandlerFuncWithErrCode>,
pub invalid_tss: Entry<HandlerFuncWithErrCode>,
pub segment_not_present: Entry<HandlerFuncWithErrCode>,
pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
pub page_fault: Entry<PageFaultHandlerFunc>,
pub x87_floating_point: Entry<HandlerFunc>,
pub alignment_check: Entry<HandlerFuncWithErrCode>,
pub machine_check: Entry<HandlerFunc>,
pub simd_floating_point: Entry<HandlerFunc>,
pub virtualization: Entry<HandlerFunc>,
pub security_exception: Entry<HandlerFuncWithErrCode>,
// some fields omitted
}
```
每一个字段都是 [`idt::Entry<F>`] 类型这个类型包含了一条完整的IDT条目定义参见上文。 其泛型参数 `F` 定义了中断处理函数的类型,在有些字段中该参数为 [`HandlerFunc`],而有些则是 [`HandlerFuncWithErrCode`],而对于 page fault 这种特殊异常,则为 [`PageFaultHandlerFunc`]。
[`idt::Entry<F>`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.Entry.html
[`HandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFunc.html
[`HandlerFuncWithErrCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.HandlerFuncWithErrCode.html
[`PageFaultHandlerFunc`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/type.PageFaultHandlerFunc.html
首先让我们看一看 `HandlerFunc` 类型的定义:
```rust
type HandlerFunc = extern "x86-interrupt" fn(_: InterruptStackFrame);
```
这是一个针对 `extern "x86-interrupt" fn` 类型的 [类型别名][type alias]。`extern` 关键字使用 [外部调用约定][foreign calling convention] 定义了一个函数这种定义方式多用于和C语言代码通信`extern "C" fn`),那么这里的外部调用约定又究竟调用了哪些东西?
[type alias]: https://doc.rust-lang.org/book/ch19-04-advanced-types.html#creating-type-synonyms-with-type-aliases
[foreign calling convention]: https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions
## 中断调用约定
异常触发十分类似于函数调用CPU会直接跳转到处理函数的第一个指令处开始执行执行结束后CPU会跳转到返回地址并继续执行之前的函数调用。
然而两者最大的不同点是:函数调用是由编译器通过 `call` 指令主动发起的,而错误处理函数则可能会由 _任何_ 指令触发。要了解这两者所造成影响的不同,我们需要更深入的追踪函数调用。
[调用约定][Calling conventions] 指定了函数调用的详细信息,比如可以指定函数的参数存放在哪里(寄存器,或者栈,或者别的什么地方)以及如何返回结果。在 x86_64 Linux 中以下规则适用于C语言函数指定于 [System V ABI] 标准):
[Calling conventions]: https://en.wikipedia.org/wiki/Calling_convention
[System V ABI]: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
- 前六个整型参数从寄存器传入 `rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9`
- 其他参数从栈传入
- 函数返回值存放在 `rax` 和 `rdx`
注意Rust并不遵循C ABI而是遵循自己的一套规则即 [尚未正式发布的 Rust ABI 草案][rust abi],所以这些规则仅在使用 `extern "C" fn` 对函数进行定义时才会使用。
[rust abi]: https://github.com/rust-lang/rfcs/issues/600
### 保留寄存器和临时寄存器
调用约定将寄存器分为两部分_保留寄存器_ 和 _临时寄存器_ 。
_保留寄存器_ 的值应当在函数调用时保持不变,所以被调用的函数( _"callee"_ )只有在保证"返回之前将这些寄存器的值恢复到初始值"的前提下,才被允许覆写这些寄存器的值, 在函数开始时将这类寄存器的值存入栈中,并在返回之前将之恢复到寄存器中是一种十分常见的做法。
而 _临时寄存器_ 则相反,被调用函数可以无限制的反复写入寄存器,若调用者希望此类寄存器在函数调用后保持数值不变,则需要自己来处理备份和恢复过程(例如将其数值保存在栈中),因而这类寄存器又被称为 _caller-saved_。
在 x86_64 架构下C调用约定指定了这些寄存器分类
| 保留寄存器 | 临时寄存器 |
| ----------------------------------------------- | ----------------------------------------------------------- |
| `rbp`, `rbx`, `rsp`, `r12`, `r13`, `r14`, `r15` | `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11` |
| _callee-saved_ | _caller-saved_ |
编译器已经内置了这些规则,因而可以自动生成保证程序正常执行的指令。例如绝大多数函数的汇编指令都以 `push rbp` 开头,也就是将 `rbp` 的值备份到栈中(因为它是 `callee-saved` 型寄存器)。
### 保存所有寄存器数据
区别于函数调用,异常在执行 _任何_ 指令时都有可能发生。在大多数情况下,我们在编译期不可能知道程序跑起来会发生什么异常。比如编译器无法预知某条指令是否会触发 page fault 或者 stack overflow。
正因我们不知道异常会何时发生,所以我们无法预先保存寄存器。这意味着我们无法使用依赖调用方备份 (caller-saved) 的寄存器的调用传统作为异常处理程序。因此我们需要一个保存所有寄存器的传统。x86-interrupt 恰巧就是其中之一,它可以保证在函数返回时,寄存器里的值均返回原样。
但请注意,这并不意味着所有寄存器都会在进入函数时备份入栈。编译器仅会备份被函数覆写的寄存器,继而为只使用几个寄存器的短小函数生成高效的代码。
### 中断栈帧
当一个常规函数调用发生时(使用 `call` 指令CPU会在跳转目标函数之前将返回地址入栈。当函数返回时使用 `ret` 指令CPU会在跳回目标函数之前弹出返回地址。所以常规函数调用的栈帧看起来是这样的
![function stack frame](function-stack-frame.svg)
对于错误和中断处理函数仅仅压入一个返回地址并不足够因为中断处理函数通常会运行在一个不那么一样的上下文中栈指针、CPU flags等等。所以CPU在遇到中断发生时是这么处理的
1. **对齐栈指针**: 任何指令都有可能触发中断所以栈指针可能是任何值而部分CPU指令比如部分SSE指令需要栈指针16字节边界对齐因此CPU会在中断触发后立刻为其进行对齐。
2. **切换栈** (部分情况下): 当CPU特权等级改变时例如当一个用户态程序触发CPU异常时会触发栈切换。该行为也可能被所谓的 _中断栈表_ 配置,在特定中断中触发,关于该表,我们会在下一篇文章做出讲解。
3. **压入旧的栈指针**: 当中断发生后栈指针对齐之前CPU会将栈指针寄存器`rsp`)和栈段寄存器(`ss`)的数据入栈,由此可在中断处理函数返回后,恢复上一层的栈指针。
4. **压入并更新 `RFLAGS` 寄存器**: [`RFLAGS`] 寄存器包含了各式各样的控制位和状态位当中断发生时CPU会改变其中的部分数值并将旧值入栈。
5. **压入指令指针**: 在跳转中断处理函数之前CPU会将指令指针寄存器`rip`)和代码段寄存器(`cs`)的数据入栈,此过程与常规函数调用中返回地址入栈类似。
6. **压入错误码** (针对部分异常): 对于部分特定的异常,比如 page faults CPU会推入一个错误码用于标记错误的成因。
7. **执行中断处理函数**: CPU会读取对应IDT条目中描述的中断处理函数对应的地址和段描述符将两者载入 `rip` 和 `cs` 以开始运行处理函数。
[`RFLAGS`]: https://en.wikipedia.org/wiki/FLAGS_register
所以 _中断栈帧_ 看起来是这样的:
![interrupt stack frame](exception-stack-frame.svg)
在 `x86_64` crate 中,中断栈帧已经被 [`InterruptStackFrame`] 结构完整表达,该结构会以 `&mut` 的形式传入处理函数,并可以用于查询错误发生的更详细的原因。但该结构并不包含错误码字段,因为只有极少量的错误会传入错误码,所以对于这类需要传入 `error_code` 的错误,其函数类型变为了 [`HandlerFuncWithErrCode`]。
[`InterruptStackFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptStackFrame.html
### 幕后花絮
`x86-interrupt` 调用约定是一个十分厉害的抽象,它几乎隐藏了所有错误处理函数中的凌乱细节,但尽管如此,了解一下水面下发生的事情还是有用的。我们来简单介绍一下被 `x86-interrupt` 隐藏起来的行为:
- **传递参数**: 绝大多数指定参数的调用约定都是期望通过寄存器取得参数的,但事实上这是无法实现的,因为我们不能在备份寄存器数据之前就将其复写。`x86-interrupt` 的解决方案时,将参数以指定的偏移量放到栈上。
- **使用 `iretq` 返回**: 由于中断栈帧和普通函数调用的栈帧是完全不同的,我们无法通过 `ret` 指令直接返回,所以此时必须使用 `iretq` 指令。
- **处理错误码**: 部分异常传入的错误码会让错误处理更加复杂,它会造成栈指针对齐失效(见下一条),而且需要在返回之前从栈中弹出去。好在 `x86-interrupt` 为我们挡住了这些额外的复杂度。但是它无法判断哪个异常对应哪个处理函数,所以它需要从函数参数数量上推断一些信息,因此程序员需要为每个异常使用正确的函数类型。当然你已经不需要烦恼这些, `x86_64` crate 中的 `InterruptDescriptorTable` 已经帮助你完成了定义。
- **对齐栈**: 对于一些指令尤其是SSE指令而言它们需要提前进行16字节边界对齐操作通常而言CPU在异常发生之后就会自动完成这一步。但是部分异常会由于传入错误码而破坏掉本应完成的对齐操作此时 `x86-interrupt` 会为我们重新完成对齐。
如果你对更多细节有兴趣:我们还有关于使用 [裸函数][naked functions] 展开异常处理的一个系列章节,参见 [文末][too-much-magic]。
[naked functions]: https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md
[too-much-magic]: #hei-mo-fa-you-dian-duo
## 实现
那么理论知识暂且到此为止该开始为我们的内核实现CPU异常处理了。首先我们在 `src/interrupts.rs` 创建一个模块,并加入函数 `init_idt` 用来创建一个新的 `InterruptDescriptorTable`
``` rust
// in src/lib.rs
pub mod interrupts;
// in src/interrupts.rs
use x86_64::structures::idt::InterruptDescriptorTable;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
}
```
现在我们就可以添加处理函数了,首先给 [breakpoint exception] 添加。该异常是一个绝佳的测试途径,因为它唯一的目的就是在 `int3` 指令执行时暂停程序运行。
[breakpoint exception]: https://wiki.osdev.org/Exceptions#Breakpoint
breakpoint exception 通常被用在调试器中:当程序员为程序打上断点,调试器会将对应的位置覆写为 `int3` 指令CPU执行该指令后就会抛出 breakpoint exception 异常。在调试完毕,需要程序继续运行时,调试器就会将原指令覆写回 `int3` 的位置。如果要了解更多细节,请查阅 ["_调试器是如何工作的_"]["_How debuggers work_"] 系列。
["_How debuggers work_"]: https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
不过现在我们还不需要覆写指令,只需要打印一行日志,表明接收到了这个异常,然后让程序继续运行即可。那么我们就来创建一个简单的 `breakpoint_handler` 方法并加入IDT中
```rust
// in src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
}
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame)
{
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
```
现在,我们的处理函数应当会输出一行信息以及完整的栈帧。
但当我们尝试编译的时候,报出了下面的错误:
```
error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180)
--> src/main.rs:53:1
|
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
54 | | println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
55 | | }
| |_^
|
= help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable
```
这是因为 `x86-interrupt` 并不是稳定特性,需要手动启用,只需要在我们的 `lib.rs` 中加入 `#![feature(abi_x86_interrupt)]` 开关即可。
### 载入 IDT
要让CPU使用新的中断描述符表我们需要使用 [`lidt`] 指令来装载一下,`x86_64` 的 `InterruptDescriptorTable` 结构提供了 [`load`][InterruptDescriptorTable::load] 函数用来实现这个需求。让我们来试一下:
[`lidt`]: https://www.felixcloutier.com/x86/lgdt:lidt
[InterruptDescriptorTable::load]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html#method.load
```rust
// in src/interrupts.rs
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.load();
}
```
再次尝试编译,又出现了新的错误:
```
error: `idt` does not live long enough
--> src/interrupts/mod.rs:43:5
|
43 | idt.load();
| ^^^ does not live long enough
44 | }
| - borrowed value only lives until here
|
= note: borrowed value must be valid for the static lifetime...
```
原来 `load` 函数要求的生命周期为 `&'static self` 也就是整个程序的生命周期其原因就是CPU在接收到下一个IDT之前会一直使用这个描述符表。如果生命周期小于 `'static` 很可能就会出现使用已释放对象的bug。
问题至此已经很清晰了,我们的 `idt` 是创建在栈上的,它的生命周期仅限于 `init` 函数执行期间之后这部分栈内存就会被其他函数调用CPU再来访问IDT的话只会读取到一段随机数据。好在 `InterruptDescriptorTable::load` 被严格定义了函数生命周期限制,这样 Rust 编译器就可以在编译时就发现这些潜在问题。
要修复这些错误很简单,让 `idt` 具备 `'static` 类型的生命周期即可,我们可以使用 [`Box`] 在堆上申请一段内存,并转化为 `'static` 指针即可,但问题是我们正在写的东西是操作系统内核,(暂时)并没有堆这种东西。
[`Box`]: https://doc.rust-lang.org/std/boxed/struct.Box.html
作为替代我们可以试着直接将IDT定义为 `'static` 变量:
```rust
static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
```
然而这样就会引入一个新问题:静态变量是不可修改的,这样我们就无法在 `init` 函数中修改里面的数据了,所以需要把变量类型修改为 [`static mut`]
[`static mut`]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable
```rust
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
pub fn init_idt() {
unsafe {
IDT.breakpoint.set_handler_fn(breakpoint_handler);
IDT.load();
}
}
```
这样就不会有编译错误了,但是这并不符合官方推荐的编码习惯,因为理论上说 `static mut` 类型的变量很容易形成数据竞争,所以需要用 [`unsafe` 代码块][`unsafe` block] 修饰调用语句。
[`unsafe` block]: https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers
#### 懒加载拯救世界
好在还有 `lazy_static` 宏可以用,区别于普通 `static` 变量在编译器求值,这个宏可以使代码块内的 `static` 变量在第一次取值时求值。所以,我们完全可以把初始化代码写在变量定义的代码块里,同时也不影响后续的取值。
在 [创建VGA字符缓冲的单例][vga text buffer lazy static] 时我们已经引入了 `lazy_static` crate所以我们可以直接使用 `lazy_static!` 来创建IDT
[vga text buffer lazy static]: @/edition-2/posts/03-vga-text-buffer/index.md#lazy-statics
```rust
// in src/interrupts.rs
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
```
现在碍眼的 `unsafe` 代码块成功被去掉了,尽管 `lazy_static!` 的内部依然使用了 `unsafe` 代码块,但是至少它已经抽象为了一个安全接口。
### 跑起来
最后一步就是在 `main.rs` 里执行 `init_idt` 函数以在我们的内核里装载IDT但不要直接调用而应在 `lib.rs` 里封装一个 `init` 函数出来:
```rust
// in src/lib.rs
pub fn init() {
interrupts::init_idt();
}
```
这样我们就可以把所有初始化逻辑都集中在一个函数里,从而让 `main.rs` 、 `lib.rs` 以及单元测试中的 `_start` 共享初始化逻辑。
现在我们更新一下 `main.rs` 中的 `_start` 函数,调用 `init` 并手动触发一次 breakpoint exception
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init(); // new
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3(); // new
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
当我们在QEMU中运行之后`cargo run`),效果是这样的:
![QEMU printing `EXCEPTION: BREAKPOINT` and the interrupt stack frame](qemu-breakpoint-exception.png)
成功了CPU成功调用了中断处理函数并打印出了信息然后返回 `_start` 函数打印出了 `It did not crash!`。
我们可以看到,中断栈帧告诉了我们当错误发生时指令和栈指针的具体数值,这些信息在我们调试意外错误的时候非常有用。
### 添加测试
那么让我们添加一个测试用例,用来确保以上工作成果可以顺利运行。首先需要在 `_start` 函数中调用 `init`
```rust
// in src/lib.rs
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init(); // new
test_main();
loop {}
}
```
注意,这里的 `_start` 会在 `cargo test --lib` 这条命令的上下文中运行,而 `lib.rs` 的执行环境完全独立于 `main.rs`,所以我们需要在运行测试之前调用 `init` 装载IDT。
那么我们接着创建一个测试用例 `test_breakpoint_exception`
```rust
// in src/interrupts.rs
#[test_case]
fn test_breakpoint_exception() {
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3();
}
```
该测试仅调用了 `int3` 函数以触发 breakpoint exception通过查看这个函数是否能够继续运行下去就可以确认我们对应的中断处理函数是否工作正常。
现在,你可以执行 `cargo test` 来运行所有测试,或者执行 `cargo test --lib` 来运行 `lib.rs` 及其子模块中包含的测试,最终输出如下:
```
blog_os::interrupts::test_breakpoint_exception... [ok]
```
## 黑魔法有点多?
相对来说,`x86-interrupt` 调用约定和 [`InterruptDescriptorTable`] 类型让错误处理变得直截了当,如果这对你来说太过于神奇,进而想要了解错误处理中的所有隐秘细节,我们推荐读一下这些:[“使用裸函数处理错误”][“Handling Exceptions with Naked Functions”] 系列文章展示了如何在不使用 `x86-interrupt` 的前提下创建IDT。但是需要注意的是这些文章都是在 `x86-interrupt` 调用约定和 `x86_64` crate 出现之前的产物,这些东西属于博客的 [第一版][first edition],不排除信息已经过期了的可能。
[“Handling Exceptions with Naked Functions”]: @/edition-1/extra/naked-exceptions/_index.md
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[first edition]: @/edition-1/_index.md
## 接下来是?
我们已经成功捕获了第一个异常,并从异常中成功恢复,下一步就是试着捕获所有异常,如果有未捕获的异常就会触发致命的[triple fault],那就只能重启整个系统了。下一篇文章会展开说我们如何通过正确捕捉[double faults]来避免这种情况。
[triple fault]: https://wiki.osdev.org/Triple_Fault
[double faults]: https://wiki.osdev.org/Double_Fault#Double_Fault

View File

@@ -1,570 +0,0 @@
+++
title = "خطاهای دوگانه"
weight = 6
path = "fa/double-fault-exceptions"
date = 2018-06-18
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "3ac829171218156c07ce9a27186fee58e3a5521e"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
این پست به طور دقیق جزئیات استثنای خطای دوگانه (ترجمه: double fault exception) را بررسی می‌کند، این استثنا هنگامی رخ می‌دهد که CPU نتواند یک کنترل کننده استثنا را فراخوانی کند. با کنترل این استثنا، از بروز _خطاهای سه گانه_ (ترجمه: triple faults) کشنده که باعث ریست (کلمه: reset) شدن سیستم می‌شوند، جلوگیری می‌کنیم. برای جلوگیری از خطاهای سه گانه در همه موارد، ما همچنین یک _Interrupt Stack Table_ را تنظیم کرده‌ایم تا خطاهای دوگانه را روی یک پشته هسته جداگانه بگیرد.
<!-- more -->
این بلاگ بصورت آزاد روی [گیت‌هاب] توسعه داده شده است. اگر شما مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. شما همچنین می‌توانید [در زیر] این پست کامنت بگذارید. منبع کد کامل این پست را می‌توانید در بِرَنچ [`post-06`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-06
<!-- toc -->
## خطای دوگانه چیست؟
به عبارت ساده، خطای دوگانه یک استثنای به خصوص است و هنگامی رخ می‌دهد که CPU نتواند یک کنترل کننده استثنا را فراخوانی کند. به عنوان مثال، این اتفاق هنگامی رخ می‌دهد که یک page fault (ترجمه: خطای صفحه) رخ دهد اما هیچ کنترل کننده خطایی در [جدول توصیف کننده وقفه][IDT] (ترجمه: Interrupt Descriptor Table) ثبت نشده باشد. بنابراین به نوعی شبیه بلاک‌های همه گیر در زبان‌های برنامه‌نویسی با استثناها می‌باشد، به عنوان مثال `catch(...)` در ++C یا `catch(Exception e)` در جاوا و #C.
[IDT]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
خطای دوگانه مانند یک استثنای عادی رفتار می‌کند. دارای شماره وکتور (کلمه: vector) `8` است و ما می‌توانیم یک تابع طبیعی کنترل کننده برای آن در IDT تعریف کنیم. تهیه یک کنترل کننده خطای دوگانه بسیار مهم است، زیرا اگر یک خطای دوگانه کنترل نشود، یک خطای کشنده سه گانه رخ می‌دهد. خطاهای سه گانه قابل کشف نیستند و اکثر سخت افزارها با تنظیم مجدد سیستم واکنش نشان می‌دهند.
### راه‌اندازی یک خطای دوگانه
بیایید یک خطای دوگانه را با راه‌اندازی (ترجمه: triggering) یک استثنا برای آن ایجاد کنیم، ما هنوز یک تابع کنترل کننده تعریف نکرده‌ایم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// trigger a page fault
unsafe {
*(0xdeadbeef as *mut u8) = 42;
};
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
برای نوشتن در آدرس نامعتبر `0xdeadbeef` از` unsafe` استفاده می‌کنیم. آدرس مجازی در جداول صفحه به آدرس فیزیکی مپ نمی‌شود، بنابراین خطای صفحه رخ می‌دهد. ما یک کنترل کننده خطای صفحه در [IDT] خود ثبت نکرده‌ایم، بنابراین یک خطای دوگانه رخ می‌دهد.
حال وقتی هسته را اجرا می‌کنیم، می‌بینیم که وارد یک حلقه بوت بی‌پایان می‌شود. دلایل حلقه بوت به شرح زیر است:
۱. سی‌پی‌یو سعی به نوشتن در `0xdeadbeef` دارد، که باعث خطای صفحه می‌شود.
۲. سی‌پی‌یو به ورودی مربوطه در IDT نگاه می‌کند و می‌بیند که هیچ تابع کنترل کننده‌ای مشخص نشده است. بنابراین، نمی‌تواند کنترل کننده خطای صفحه را فراخوانی کند و یک خطای دوگانه رخ می‌دهد.
۳. سی‌پی‌یو ورودی IDT کنترل کننده خطای دو گانه را بررسی می‌کند، اما این ورودی هم تابع کنترل کننده‌ای را مشخص نمی‌کند. بنابراین، یک خطای _سه‌گانه_ رخ می‌دهد.
۴. خطای سه گانه کشنده است. QEMU مانند اکثر سخت افزارهای واقعی به آن واکنش نشان داده دستور ریست شدن سیستم را صادر می‌کند.
بنابراین برای جلوگیری از این خطای سه‌گانه، باید یک تابع کنترل کننده برای خطاهای صفحه یا یک کنترل کننده خطای دوگانه ارائه دهیم. ما می‌خواهیم در همه موارد از خطاهای سه گانه جلوگیری کنیم، بنابراین بیایید با یک کنترل کننده خطای دوگانه شروع کنیم که برای همه انواع استثنا بدون کنترل فراخوانی می‌شود.
## کنترل کننده خطای دوگانه
خطای دوگانه یک استثنا عادی با کد خطا است، بنابراین می‌توانیم یک تابع کنترل کننده مشابه کنترل کننده نقطه شکست (ترجمه: breakpoint) تعیین کنیم:
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler); // new
idt
};
}
// new
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame, _error_code: u64) -> !
{
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}
```
کنترل کننده ما یک پیام خطای کوتاه چاپ می‌کند و قاب پشته استثنا را تخلیه می‌کند. کد خطای کنترل کننده خطای دوگانه همیشه صفر است، بنابراین دلیلی برای چاپ آن وجود ندارد. یک تفاوت در کنترل کننده نقطه شکست این است که کنترل کننده خطای دوگانه [_diverging_] \(ترجمه: واگرا) است. چون معماری `x86_64` امکان بازگشت از یک استثنا خطای دوگانه را ندارد.
[_diverging_]: https://doc.rust-lang.org/stable/rust-by-example/fn/diverging.html
حال وقتی هسته را اجرا می‌کنیم، باید ببینیم که کنترل کننده خطای دوگانه فراخوانی می‌شود:
![QEMU printing `EXCEPTION: DOUBLE FAULT` and the exception stack frame](qemu-catch-double-fault.png)
کار کرد! آن‌چه این بار اتفاق می‌افتد بصورت زیر است:
۱. سی‌پی‌یو سعی به نوشتن در `0xdeadbeef` دارد، که باعث خطای صفحه می‌شود.
۲. مانند قبل، سی‌پی‌یو به ورودی مربوطه در IDT نگاه می‌کند و می‌بیند که هیچ تابع کنترل کننده‌ای مشخص نشده است. بنابراین، یک خطای دوگانه رخ می‌دهد.
۳. سی‌پی‌یو به کنترل کننده خطای دوگانه - که اکنون وجود دارد - می‌رود.
خطای سه گانه (و حلقه بوت) دیگر رخ نمی‌دهد، زیرا اکنون CPU می‌تواند کنترل کننده خطای دوگانه را فراخوانی کند.
این کاملاً ساده بود! پس چرا ما برای این موضوع به یک پست کامل نیاز داریم؟ خب، ما اکنون قادر به ردیابی _اکثر_ خطاهای دوگانه هستیم، اما مواردی وجود دارد که رویکرد فعلی ما کافی نیست.
## علل رخ داد خطای دوگانه
قبل از بررسی موارد خاص، باید علل دقیق خطاهای دوگانه را بدانیم. در بالا، ما از یک تعریف کاملا مبهم استفاده کردیم:
> خطای دوگانه یک استثنای به خصوص است و هنگامی رخ می‌دهد که CPU نتواند یک کنترل کننده استثنا را فراخوانی کند.
عبارت _“fails to invoke”_ دقیقا چه معنایی دارد؟ کنترل کننده وجود ندارد؟ کنترل کننده [خارج شده][swapped out] \(منظور این است که آیا صفحه مربوط به کنترل کننده از حافظه خارج شده)؟ و اگر کنترل کننده خودش باعث رخ دادن یک استثناها شود، چه اتفاقی رخ می‌دهد؟
[swapped out]: http://pages.cs.wisc.edu/~remzi/OSTEP/vm-beyondphys.pdf
به عنوان مثال، چه اتفاقی می‌افتد اگر:
۱. یک استثنای نقطه شکست رخ می‌دهد، آیا تابع کنترل کننده مربوطه خارج شده است؟
۲. یک خطای صفحه رخ می‌دهد، آیا کنترل کننده خطای صفحه خارج شده است؟
۳. کنترل کننده‌ی «تقسیم بر صفر» باعث رخ دادن یک استثنای نقطه شکست می‌شود، آیا کنترل کننده نقطه شکست خارج شده است؟
۴. هسته ما پشته خود را سرریز می‌کند و آیا _صفحه محافظ_ (ترجمه: guard page) ضربه می‌خورد؟
خوشبختانه، کتابچه راهنمای AMD64 ([PDF][AMD64 manual]) یک تعریف دقیق دارد (در بخش 8.2.9). مطابق آن، "یک استثنای خطای دوگانه _می‌تواند_ زمانی اتفاق بیفتد که یک استثنا دوم هنگام کار با یک کنترل کننده استثنا قبلی (اول) رخ دهد". _"می تواند"_ مهم است: فقط ترکیبی بسیار خاص از استثناها منجر به خطای دوگانه می‌شود. این ترکیبات عبارتند از:
استثنای اول | استثنای دوم
----------------|-----------------
[Divide-by-zero],<br>[Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault] | [Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault]
[Page Fault] | [Page Fault],<br>[Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault]
[Divide-by-zero]: https://wiki.osdev.org/Exceptions#Division_Error
[Invalid TSS]: https://wiki.osdev.org/Exceptions#Invalid_TSS
[Segment Not Present]: https://wiki.osdev.org/Exceptions#Segment_Not_Present
[Stack-Segment Fault]: https://wiki.osdev.org/Exceptions#Stack-Segment_Fault
[General Protection Fault]: https://wiki.osdev.org/Exceptions#General_Protection_Fault
[Page Fault]: https://wiki.osdev.org/Exceptions#Page_Fault
[AMD64 manual]: https://www.amd.com/system/files/TechDocs/24593.pdf
بنابراین به عنوان مثال، یک خطای تقسیم بر صفر (ترجمه: Divide-by-zero) و به دنبال آن خطای صفحه (ترجمه: Page Fault)، خوب است (کنترل کننده خطای صفحه فراخوانی می‌شود)، اما خطای تقسیم بر صفر و به دنبال آن یک خطای محافظت عمومی (ترجمه: General Protection) منجر به خطای دوگانه می شود.
با کمک این جدول می‌توانیم به سه مورد اول از سوال‌های بالا پاسخ دهیم:
۱. اگر یک استثنای نقطه شکست اتفاق بیفتد و تابع مربوط به کنترل کننده آن خارج شده باشد، یک _خطای صفحه_ رخ می‌دهد و _کنترل کننده خطای صفحه_ فراخوانی می‌شود.
۲. اگر خطای صفحه رخ دهد و کنترل کننده خطای صفحه خارج شده باشد، یک _خطای دوگانه_ رخ می‌دهد و _کنترل کننده خطای دوگانه_ فراخوانی می‌شود.
۳. اگر یک کنترل کننده تقسیم بر صفر باعث استثنای نقطه شکست شود، CPU سعی می‌کند تا کنترل کننده نقطه شکست را فراخوانی کند. اگر کنترل کننده نقطه شکست خارج شده باشد، یک _خطای صفحه_ رخ می‌دهد و _کنترل کننده خطای صفحه_ فراخوانی می‌شود.
در حقیقت، حتی موارد استثنا بدون تابع کنترل کننده در IDT نیز از این طرح پیروی می‌کند: وقتی استثنا رخ می‌دهد، CPU سعی می‌کند ورودی IDT مربوطه را بخواند. از آن‌جا که ورودی 0 است، که یک ورودی IDT معتبر نیست، یک _خطای محافظت کلی_ رخ می‌دهد. ما یک تابع کنترل کننده برای خطای محافظت عمومی نیز تعریف نکردیم، بنابراین یک خطای محافظت عمومی دیگر رخ می‌دهد. طبق جدول، این منجر به یک خطای دوگانه می‌شود.
### سرریزِ پشته‌ی هسته
بیایید به سوال چهارم نگاه کنیم:
> چه اتفاقی می‌افتد اگر هسته ما پشته خود را سرریز کند و صفحه محافظ ضربه بخورد؟
یک صفحه محافظ یک صفحه حافظه ویژه در پایین پشته است که امکان تشخیصِ سرریز پشته را فراهم می‌کند. صفحه به هیچ قاب فیزیکی مپ نشده است، بنابراین دسترسی به آن باعث خطای صفحه می‌شود به جای اینکه بی صدا حافظه دیگر را خراب کند. بوت‌لودر یک صفحه محافظ برای پشته هسته ما تنظیم می‌کند، بنابراین سرریز پشته باعث _خطای صفحه_ می‌شود.
هنگامی که خطای صفحه رخ می‌دهد، پردازنده به دنبال کنترل کننده خطای صفحه در IDT است و سعی می‌کند تا [قاب پشته وقفه][interrupt stack frame] را به پشته پوش می‌کند. با این حال، اشاره‌گر پشته فعلی هنوز به صفحه محافظی اشاره می‌کند که موجود نیست. بنابراین، خطای صفحه دوم رخ می‌دهد، که باعث خطای دوگانه می‌شود (مطابق جدول فوق).
[interrupt stack frame]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-stack-frame
بنابراین حالا پردازنده سعی می‌کند _کنترل کننده خطای دوگانه_ را فراخوانی کند. با این حال، هنگام رخ دادن خطای دوگانه پردازنده سعی می‌کند تا قاب پشته استثنا را نیز پوش کند. اشاره‌گر پشته هنوز به سمت صفحه محافظ است، بنابراین یک خطای صفحه _سوم_ رخ می‌هد که باعث یک _خطای سه‌گانه_ و راه اندازی مجدد سیستم می‌شود. بنابراین کنترل کننده خطای دوگانه فعلی ما نمی‌تواند از خطای سه‌گانه در این مورد جلوگیری کند.
بیایید خودمان امتحان کنیم! ما می‌توانیم با فراخوانی تابعی که به طور بی‌وقفه بازگشت می‌یابد، به راحتی سرریز پشته هسته را تحریک کنیم (باعث رخ دادن یک سرریز پشته هسته شویم):
```rust
// in src/main.rs
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
fn stack_overflow() {
stack_overflow(); // for each recursion, the return address is pushed
}
// trigger a stack overflow
stack_overflow();
[] // test_main(), println(…), and loop {}
}
```
وقتی این کد را در QEMU امتحان می‌کنیم، می‌بینیم که سیستم دوباره وارد یک حلقه بوت می‌شود.
بنابراین چگونه می‌توانیم از بروز این مشکل جلوگیری کنیم؟ ما نمی‌توانیم پوش کردن قاب پشته استثنا را حذف کنیم، زیرا پردازنده خود این کار را انجام می‌دهد. بنابراین ما باید به نحوی اطمینان حاصل کنیم که وقتی یک استثنای خطای دوگانه رخ می‌دهد، پشته همیشه معتبر است. خوشبختانه، معماری x86_64 راه حلی برای این مشکل دارد.
## تعویض پشته‌ها
معماری x86_64 قادر است در صورت وقوع یک استثنا به یک پشته از پیش تعریف شده و شناخته شده تعویض شود. این تعویض در سطح سخت افزاری اتفاق می‌افتد، بنابراین می‌توان آن را قبل از اینکه پردازنده قاب پشته استثنا را پوش کند، انجام داد.
مکانیزم تعویض به عنوان _Interrupt Stack Table_ (IST) پیاده‌سازی می‌شود. IST جدولی است با 7 اشاره‌گر برای دسته های معروف. در شبه‌ کد شبیه Rust:
```rust
struct InterruptStackTable {
stack_pointers: [Option<StackPointer>; 7],
}
```
برای هر کنترل کننده استثنا، می‌توانیم یک پشته از IST از طریق فیلد `stack_pointers` مربوط به [IDT entry] انتخاب کنیم. به عنوان مثال، ما می‌توانیم از اولین پشته در IST برای کنترل کننده خطای دوگانه استفاده کنیم. هرگاه خطای دوگانه رخ دهد، پردازنده به طور خودکار به این پشته تغییر می‌کند. این تعویض قبل از پوش کردن هر چیزی اتفاق می‌افتد، بنابراین از خطای سه‌گانه جلوگیری می‌کند.
[IDT entry]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
### IST و TSS
جدول پشته وقفه (ترجمه: Interrupt Stack Table: IST) بخشی از یک ساختار قدیمی است که به آن _[سگمنت وضعیت پروسه]_ \(Task State Segment: TSS) گفته می‌شود. TSS برای نگهداری اطلاعات مختلف (به عنوان مثال وضعیت ثبات پردازنده) در مورد یک پروسه در حالت 32 بیتی استفاده می‌شد و به عنوان مثال برای [تعویض سخت‌افزاری context] \(ترجمه: hardware context switching) استفاده می‌شد. با این حال، تعویض سخت‌افزاری context دیگر در حالت 64 بیتی پشتیبانی نمی‌شود و قالب TSS کاملاً تغییر کرده است.
[سگمنت وضعیت پروسه]: https://en.wikipedia.org/wiki/Task_state_segment
[تعویض سخت‌افزاری context]: https://wiki.osdev.org/Context_Switching#Hardware_Context_Switching
در x86_64، دیگر TSS هیچ اطلاعات خاصی برای پرسه‌ها ندارد. در عوض، دو جدول پشته را در خود جای داده است (IST یکی از آنهاست). تنها فیلد مشترک بین TSS 32-bit و TSS 64-bit اشاره‌گر به [بیت‌مپ مجوزهای پورت I/O] است.
[بیت‌مپ مجوزهای پورت I/O]: https://en.wikipedia.org/wiki/Task_state_segment#I.2FO_port_permissions
فرمت TSS 64-bit مانند زیر است:
فیلد | نوع
------ | ----------------
<span style="opacity: 0.5">(reserved)</span> | `u32`
Privilege Stack Table | `[u64; 3]`
<span style="opacity: 0.5">(reserved)</span> | `u64`
Interrupt Stack Table | `[u64; 7]`
<span style="opacity: 0.5">(reserved)</span> | `u64`
<span style="opacity: 0.5">(reserved)</span> | `u16`
I/O Map Base Address | `u16`
وقتی سطح ممتاز تغییر می‌کند، پردازنده از _Privilege Stack Table_ استفاده می‌کند. به عنوان مثال، اگر یک استثنا در حالی که CPU در حالت کاربر است (سطح ممتاز 3) رخ دهد، CPU معمولاً قبل از فراخوانی کنترل کننده استثنا، به حالت هسته تغییر می‌کند (سطح امتیاز 0). در این حالت، CPU به پشته صفرم در جدول پشته ممتاز تغییر وضعیت می دهد (از آنجا که 0، سطح ممتاز هدف است). ما هنوز هیچ برنامه حالت کاربر نداریم، بنابراین اکنون این جدول را نادیده می‌گیریم.
### ایجاد یک TSS
بیایید یک TSS جدید ایجاد کنیم که شامل یک پشته خطای دوگانه جداگانه در جدول پشته وقفه خود باشد. برای این منظور ما به یک ساختار TSS نیاز داریم. خوشبختانه کریت `x86_64` از قبل حاوی [ساختار `TaskStateSegment`] است که می‌توانیم از آن استفاده کنیم.
[ساختار `TaskStateSegment`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/tss/struct.TaskStateSegment.html
ما TSS را در یک ماژول جدید به نام `gdt` ایجاد می‌کنیم (نام این ماژول بعداً برای‌تان معنا پیدا می‌کند):
```rust
// in src/lib.rs
pub mod gdt;
// in src/gdt.rs
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 5;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end
};
tss
};
}
```
ما از `lazy_static` استفاده می‌کنیم زیرا ارزیابی کننده ثابت راست هنوز آن‌قدر توانمند نیست که بتواند این مقداردهی اولیه را در زمان کامپایل انجام دهد. ما تعریف می‌کنیم که ورودی صفرم IST پشته خطای دوگانه است (هر اندیس دیگری از IST نیز قابل استفاده است). سپس آدرس بالای یک پشته خطای دوگانه را در ورودی صفرم می‌نویسیم. ما آدرس بالایی را می‌نویسیم زیرا پشته‌های x86 به سمت پایین رشد می‌کنند، یعنی از آدرس‌های بالا به آدرس‌های پایین می‌آیند.
ما هنوز مدیریت حافظه را پیاده سازی نکرده‌ایم، بنابراین روش مناسبی برای اختصاص پشته جدید نداریم. در عوض، فعلاً از یک آرایه `static mut` به عنوان حافظه پشته استفاده میکنیم. `unsafe` لازم است زیرا هنگام دسترسی به استاتیک‌های تغییرپذیر (ترجمه: mutable)، کامپایلر نمی‌تواند عدم وجود رقابت بین داده ها را تضمین کند. مهم است که یک `static mut` باشد و نه یک استاتیک‌ تغییرناپذیر (ترجمه: immutable)، زیرا در غیر این صورت bootloader آن را به یک صفحه فقط خواندنی نگاشت می‌کند. ما در پست بعدی این را با یک تخصیص پشته مناسب جایگزین خواهیم کرد، سپس `unsafe` دیگر در این‌جا مورد نیاز نخواهد بود.
توجه داشته باشید که این پشته خطای دوگانه فاقد صفحه محافظ در برابر سرریز پشته است. یعنی ما نباید هیچ کاری که اضافه شدن ایتمی در پشته شود را انجام دهیم زیرا سرریز پشته ممکن است حافظه زیر پشته را خراب کند.
#### بارگذاری TSS
اکنون که TSS جدیدی ایجاد کردیم، به روشی نیاز داریم که به CPU بگوییم باید از آن استفاده کند. متأسفانه این کمی دشوار است، زیرا TSS به دلایل تاریخی از سیستم سگمنت‌بندی (ترجمه: segmentation) استفاده می‌کند. به جای بارگذاری مستقیم جدول، باید توصیفگر سگمنت جدیدی را به [جدول توصیف‌گر سراسری] \(Global Descriptor Table: GDT) اضافه کنیم. سپس می‌توانیم TSS خود را با فراخوانی [دستور `ltr`] با اندیس GDT مربوطه بارگذاری کنیم. (دلیل این‌که نام ماژول را `gdt` گذاشتیم نیز همین بود).
[جدول توصیف‌گر سراسری]: https://web.archive.org/web/20190217233448/https://www.flingos.co.uk/docs/reference/Global-Descriptor-Table/
[دستور `ltr`]: https://www.felixcloutier.com/x86/ltr
### جدول توصیف‌گر سراسری
جدول توصیف‌گر سراسری (GDT) یک یادگاری است که قبل از این‌که صفحه‌بندی به صورت استاندارد تبدیل شود، برای [تقسیم‌بندی حافظه] استفاده می‌شد. این مورد همچنان در حالت 64 بیتی برای موارد مختلف مانند پیکربندی هسته/کاربر یا بارگذاری TSS مورد نیاز است.
[تقسیم‌بندی حافظه]: https://en.wikipedia.org/wiki/X86_memory_segmentation
جدول توصیف‌گر سراسری، ساختاری است که شامل _بخشهای_ برنامه است. قبل از اینکه صفحه‌بندی به استاندارد تبدیل شود، از آن در معماری‌های قدیمی استفاده می‌شد تا برنامه ها را از یکدیگر جدا کند. برای کسب اطلاعات بیشتر در مورد سگمنت‌بندی، فصل مربوط به این موضوع در [کتاب “Three Easy Pieces”] را مطالعه کنید. در حالی که سگمنت‌بندی در حالت 64 بیتی دیگر پشتیبانی نمی‌شود، GDT هنوز وجود دارد. بیشتر برای دو چیز استفاده می‌شود: جابجایی بین فضای هسته و فضای کاربر، و بارگذاری ساختار TSS.
[کتاب “Three Easy Pieces”]: http://pages.cs.wisc.edu/~remzi/OSTEP/
#### ایجاد یک GDT
بیایید یک `GDT` استاتیک ایجاد کنیم که شامل یک بخش برای TSS استاتیک ما باشد:
```rust
// in src/gdt.rs
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
lazy_static! {
static ref GDT: GlobalDescriptorTable = {
let mut gdt = GlobalDescriptorTable::new();
gdt.add_entry(Descriptor::kernel_code_segment());
gdt.add_entry(Descriptor::tss_segment(&TSS));
gdt
};
}
```
ما دوباره از `lazy_static` استفاده می‌کنیم، زیرا ارزیابی کننده ثابت راست هنوز آن‌قدر توانمند نیست. ما یک GDT جدید با یک کد سگمنت و یک بخش TSS ایجاد می‌کنیم.
#### بارگذاری GDT
برای بارگذاری GDT، یک تابع جدید `gdt::init` ایجاد می‌کنیم که آن را از تابع `init` فراخوانی می‌کنیم:
```rust
// in src/gdt.rs
pub fn init() {
GDT.load();
}
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
}
```
اکنون GDT ما بارگذاری شده است (از آن‌جا که تابع `start_`، تابع `init` را فراخوانی می‌کند)، اما هنوز حلقه بوت را هنگامِ سرریز پشته مشاهده می‌کنیم.
### مراحل پایانی
مشکل این است که سگمنت‌های GDT هنوز فعال نیستند زیرا سگمنت و ثبات‌های TSS هنوز حاوی مقادیر GDT قدیمی هستند. ما همچنین باید ورودی خطای دوگانه IDT را اصلاح کنیم تا از پشته جدید استفاده کند.
به طور خلاصه، باید موارد زیر را انجام دهیم:
۱. **بارگذاری مجدد ثبات کد سگمنت**: ما GDT خود را تغییر دادیم، بنابراین باید `cs`، ثبات کد سگمنت را بارگذاری مجدد کنیم. این مورد الزامی است زیرا انتخاب‌گر سگمنت قدیمی می‌تواند اکنون توصیف‌گر دیگری از GDT را نشان دهد (به عنوان مثال توصیف کننده TSS).
۲. **بارگذاری TSS**: ما یک GDT بارگذاری کردیم که شامل یک انتخاب‌گر TSS است، اما هنوز باید به CPU بگوییم که باید از آن TSS استفاده کند.
۳. **بروزرسانی ورودی IDT**: به محض این‌که TSS بارگذاری شد، CPU به یک جدول پشته وقفه معتبر (IST) دسترسی دارد. سپس می‌توانیم به CPU بگوییم که باید با تغییر در ورودی IDT خطای دوگانه از پشته خطای دوگانه جدید استفاده کند.
برای دو مرحله اول، ما نیاز به دسترسی به متغیرهای` code_selector` و `tss_selector` در تابع `gdt::init` داریم. می‌توانیم با تبدیل آن‌ها به بخشی از استاتیک از طریق ساختار جدید `Selectors` به این هدف برسیم:
```rust
// in src/gdt.rs
use x86_64::structures::gdt::SegmentSelector;
lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}
struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}
```
اکنون می‌توانیم با استفاده از انتخاب‌گرها، ثبات بخش `cs` را بارگذاری مجدد کرده و `TSS` را بارگذاری کنیم:
```rust
// in src/gdt.rs
pub fn init() {
use x86_64::instructions::segmentation::set_cs;
use x86_64::instructions::tables::load_tss;
GDT.0.load();
unsafe {
set_cs(GDT.1.code_selector);
load_tss(GDT.1.tss_selector);
}
}
```
ما با استفاده از [`set_cs`] ثبات کد سگمنت را بارگذاری مجدد می‌کنیم و برای بارگذاری TSS با از [`load_tss`] استفاده می‌کنیم. توابع به عنوان `unsafe` علامت گذاری شده‌اند، بنابراین برای فراخوانی آن‌ها به یک بلوک `unsafe` نیاز داریم. چون ممکن است با بارگذاری انتخاب‌گرهای نامعتبر، ایمنی حافظه از بین برود.
[`set_cs`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/segmentation/fn.set_cs.html
[`load_tss`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tables/fn.load_tss.html
اکنون که یک TSS معتبر و جدول پشته‌ وقفه را بارگذاری کردیم، می‌توانیم اندیس پشته را برای کنترل کننده خطای دوگانه در IDT تنظیم کنیم:
```rust
// in src/interrupts.rs
use crate::gdt;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
unsafe {
idt.double_fault.set_handler_fn(double_fault_handler)
.set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new
}
idt
};
}
```
روش `set_stack_index` ایمن نیست زیرا فراخوان (ترجمه: caller) باید اطمینان حاصل کند که اندیس استفاده شده معتبر است و قبلاً برای استثنای دیگری استفاده نشده است.
همین! اکنون CPU باید هر زمان که خطای دوگانه رخ داد، به پشته خطای دوگانه برود. بنابراین، ما می‌توانیم _همه_ خطاهای دوگانه، از جمله سرریزهای پشته هسته را بگیریم:
![QEMU printing `EXCEPTION: DOUBLE FAULT` and a dump of the exception stack frame](qemu-double-fault-on-stack-overflow.png)
از این به بعد هرگز نباید شاهد خطای سه‌گانه باشیم! برای اطمینان از اینکه موارد بالا را به طور تصادفی نقض نمی‌کنیم، باید یک تست برای این کار اضافه کنیم.
## تست سرریز پشته
برای آزمایش ماژول `gdt` جدید و اطمینان از اینکه مدیر خطای دوگانه به درستی هنگام سرریز پشته فراخوانی شده است، می‌توانیم یک تست یکپارچه اضافه کنیم. ایده این است که یک خطای دوگانه در تابع تست ایجاد کنید و تأیید کنید که مدیر خطای دوگانه فراخوانی می‌شود.
بیایید با یک طرح مینیمال شروع کنیم:
```rust
// in tests/stack_overflow.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
مانند تست `panic_handler`، تست [بدون یک test harness] اجرا خواهد شد. زیرا پس از یک خطای دوگانه نمی‌توانیم اجرا را ادامه دهیم، بنابراین بیش از یک تست منطقی نیست. برای غیرفعال کردن test harness برای این تست، موارد زیر را به `Cargo.toml` اضافه می‌کنیم:
```toml
# in Cargo.toml
[[test]]
name = "stack_overflow"
harness = false
```
[بدون یک test harness]: @/edition-2/posts/04-testing/index.md#no-harness-tests
حال باید `cargo test --test stack_overflow` بصورت موفقیت‌آمیز کامپایل شود. البته این تست با شکست مواجه می‌شود، زیرا ماکروی `unimplemented` پنیک می‌کند.
### پیاده‌سازی `start_`
پیاده‌سازی تابع `start_` مانند این است:
```rust
// in tests/stack_overflow.rs
use blog_os::serial_print;
#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_print!("stack_overflow::stack_overflow...\t");
blog_os::gdt::init();
init_test_idt();
// trigger a stack overflow
stack_overflow();
panic!("Execution continued after stack overflow");
}
#[allow(unconditional_recursion)]
fn stack_overflow() {
stack_overflow(); // for each recursion, the return address is pushed
volatile::Volatile::new(0).read(); // prevent tail recursion optimizations
}
```
برای راه‌اندازی یک GDT جدید، تابع `gdt::init` را فراخوانی می‌کنیم. به جای فراخوانی تابع `interrupts::init_idt`، تابع `init_test_idt` را فراخوانی می‌کنیم که بزودی توضیح داده می‌شود. زیرا ما می‌خواهیم یک مدیر خطای دوگانه سفارشی ثبت کنیم که به جای پنیک کردن، دستور `exit_qemu(QemuExitCode::Success)` را انجام می‌دهد.
تابع `stack_overflow` تقریباً مشابه تابع موجود در `main.rs` است. تنها تفاوت این است که برای جلوگیری از بهینه‌سازی کامپایلر موسوم به [_tail call elimination_]، در پایان تابع، یک خواندنِ [فرارِ] \(ترجمه: volatile) اضافه به وسیله نوع [`Volatile`] انجام می‌دهیم. از جمله، این بهینه‌سازی به کامپایلر اجازه می‌دهد تابعی را که آخرین عبارت آن فراخوانی تابع بازگشتی است، به یک حلقه طبیعی تبدیل کند. بنابراین، هیچ قاب پشته اضافی برای فراخوانی تابع ایجاد نمی‌شود، پس استفاده از پشته ثابت می‌ماند.
[volatile]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
[`Volatile`]: https://docs.rs/volatile/0.2.6/volatile/struct.Volatile.html
[_tail call elimination_]: https://en.wikipedia.org/wiki/Tail_call
با این حال، در مورد ما، ما می‌خواهیم که سرریز پشته اتفاق بیفتد، بنابراین در انتهای تابع یک دستور خواندن فرار ساختگی اضافه می‌کنیم، که کامپایلر مجاز به حذف آن نیست. بنابراین، تابع دیگر _tail recursive_ نیست و از تبدیل به یک حلقه جلوگیری می‌شود. ما همچنین صفت `allow(unconditional_recursion)` را اضافه می‌کنیم تا اخطار کامپایلر را در مورد تکرار بی‌وقفه تابع خاموش نگه دارد.
### تست IDT
همانطور که در بالا ذکر شد، این تست به IDT مخصوص خود با یک مدیر خطای دوگانه سفارشی نیاز دارد. پیاده‌سازی به این شکل است:
```rust
// in tests/stack_overflow.rs
use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;
lazy_static! {
static ref TEST_IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.double_fault
.set_handler_fn(test_double_fault_handler)
.set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
}
idt
};
}
pub fn init_test_idt() {
TEST_IDT.load();
}
```
پیاده‌سازی بسیار شبیه IDT طبیعی ما در `interrupts.rs` است. مانند IDT عادی، برای مدیر خطای دوگانه به منظور جابجایی به پشته‌ای جداگانه، یک اندیس پشته را در IST تنظیم می‌کنیم. تابع `init_test_idt` با استفاده از روش `load`، آی‌دی‌تی را بر روی پردازنده بارگذاری می‌کند.
### مدیر خطای دوگانه
تنها قسمت جامانده، مدیر خطای دوگانه است که به این شکل پیاده‌سازی می‌شود:
```rust
// in tests/stack_overflow.rs
use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;
extern "x86-interrupt" fn test_double_fault_handler(
_stack_frame: InterruptStackFrame,
_error_code: u64,
) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
```
هنگامی که مدیر خطای دوگانه فراخوانی می‌شود، از QEMU با یک کد خروج موفقیت‌آمیز خارج می‌شویم، که تست را بعنوان «قبول شده» علامت‌گذاری می‌داند. از آن‌جا که تست‌های یکپارچه اجرایی‌های کاملاً مجزایی هستند، باید صفت `[feature(abi_x86_interrupt)]!#` را در بالای فایل تست تنظیم کنیم.
اکنون می‌توانیم تست را از طریق `cargo test --test stack_overflow` (یا `cargo test` برای اجرای همه تست‌ها) انجام دهیم. همانطور که انتظار می‌رفت، خروجی `stack_overflow... [ok ]` را در کنسول مشاهده می‌کنیم. خط `set_stack_index` را کامنت کنید: این امر باعث می‌شود تست از کار بیفتد.
## خلاصه
در این پست یاد گرفتیم که خطای دوگانه چیست و در چه شرایطی رخ می‌دهد. ما یک مدیر خطای دوگانه پایه اضافه کردیم که پیام خطا را چاپ می‌کند و یک تست یکپارچه برای آن اضافه کردیم.
ما همچنین تعویض پشته پشتیبانی شده سخت‌افزاری را در استثناهای خطای دوگانه فعال کردیم تا در سرریز پشته نیز کار کند. در حین پیاده‌سازی آن، ما با سگمنت وضعیت پروسه (TSS)، جدول پشته وقفه (IST) و جدول توصیف کننده سراسری (GDT) آشنا شدیم، که برای سگمنت‌بندی در معماری‌های قدیمی استفاده می‌شد.
## بعدی چیست؟
پست بعدی نحوه مدیریت وقفه‌های دستگاه‌های خارجی مانند تایمر، صفحه کلید یا کنترل کننده‌های شبکه را توضیح می‌دهد. این وقفه‌های سخت‌افزاری بسیار شبیه به استثناها هستند، به عنوان مثال آن‌ها هم از طریق IDT ارسال می‌شوند. با این حال، برخلاف استثناها، مستقیماً روی پردازنده رخ نمی‌دهند. در عوض، یک _interrupt controller_ این وقفه‌ها را جمع کرده و بسته به اولویت، آن‌ها را به CPU می‌فرستد. در بخش بعدی، مدیر وقفه [Intel 8259] \("PIC") را بررسی خواهیم کرد و نحوه پیاده‌سازی پشتیبانی صفحه کلید را یاد خواهیم گرفت.
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259

View File

@@ -1,554 +0,0 @@
+++
title = "Double Faults"
weight = 6
path = "ja/double-fault-exceptions"
date = 2018-06-18
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "27ac0e1acc36f640d7045b427da2ed65b945756b"
# GitHub usernames of the people that translated this post
translators = ["garasubo"]
+++
この記事ではCPUが例外ハンドラの呼び出しに失敗したときに起きる、ダブルフォルト例外について詳細に見ていきます。この例外を処理することによって、システムリセットを起こす重大な**トリプルフォルト**を避けることができます。あらゆる場合においてトリプルフォルトを防ぐために、ダブルフォルトを異なるカーネルスタック上でキャッチするための**割り込みスタックテーブル**をセットアップしていきます。
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください(訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-06` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-06
<!-- toc -->
## ダブルフォルトとは
簡単に言うとダブルフォルトとはCPUが例外ハンドラを呼び出すことに失敗したときに起きる特別な例外です。例えば、ページフォルトが起きたが、ページフォルトハンドラが[割り込みディスクリプタテーブル][IDT]IDT: Interrupt Descriptor Tableに登録されていないときに発生します。つまり、C++での`catch(...)`や、JavaやC#の`catch(Exception e)`ような、例外のあるプログラミング言語のcatch-allブロックのようなものです。
[IDT]: @/edition-2/posts/05-cpu-exceptions/index.ja.md#ge-riip-miji-shu-zi-biao
ダブルフォルトは通常の例外のように振る舞います。ベクター番号`8`を持ち、IDTに通常のハンドラ関数として定義できます。ダブルフォルトがうまく処理されないと、より重大な例外である**トリプルフォルト**が起きてしまうため、ダブルフォルトハンドラを設定することはとても重要です。トリプルフォルトはキャッチできず、ほとんどのハードウェアはシステムリセットを起こします。
### ダブルフォルトを起こす
ハンドラ関数を定義していない例外を発生させることでダブルフォルトを起こしてみましょう。
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// ページフォルトを起こす
unsafe {
*(0xdeadbeef as *mut u8) = 42;
};
// 前回同様
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
不正なアドレスである`0xdeadbeef`に書き込みを行うため`unsafe`を使います。この仮想アドレスはページテーブル上で物理アドレスにマップされていないため、ページフォルトが発生します。私達の[IDT]にはページフォルトが登録されていないため、ダブルフォルトが発生します。
今、私達のカーネルを起動すると、ブートループが発生します。この理由は以下の通りです:
1. CPUが`0xdeadbeef`に書き込みを試みページフォルトを起こします。
2. CPUはIDTに対応するエントリを探しに行き、ハンドラ関数が指定されていないことを発見します。結果、ページフォルトハンドラが呼び出せず、ダブルフォルトが発生します。
3. CPUはダブルフォルトハンドラのIDTエントリを見にいきますが、このエントリもハンドラ関数を指定していません。結果、**トリプルフォルト**が発生します。
4. トリプルフォルトは重大なエラーなので、QEMUはほとんどの実際のハードウェアと同様にシステムリセットを行います。
このトリプルフォルトを防ぐためには、ページフォルトかダブルフォルトのハンドラ関数を定義しないといけません。私達はすべての場合におけるトリプルフォルトを防ぎたいので、適切に処理できなかったすべての例外において呼び出されることになるダブルフォルトハンドラを定義するところからはじめましょう。
## ダブルフォルトハンドラ
ダブルフォルトは通常のエラーコードのある例外なので、ブレークポイントハンドラと同じようにハンドラ関数を指定することができます。
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler); // new
idt
};
}
// new
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame, _error_code: u64) -> !
{
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}
```
私達のハンドラは短いエラーメッセージを出力して、例外スタックフレームをダンプします。ダブルフォルトハンドラのエラーコードは常に`0`なので、出力する必要はないでしょう。ブレークポイントハンドラとの違いの一つは、ダブルフォルトハンドラは[発散する]diverging訳注: 翻訳当時、リンク先未訳)ということです。`x86_64`アーキテクチャではダブルフォルト例外から復帰することができないためです。
[発散する]: https://doc.rust-jp.rs/rust-by-example-ja/fn/diverging.html
ここで私達のカーネルを起動すると、ダブルフォルトハンドラが呼び出されていることがわかることでしょう。
![QEMU printing `EXCEPTION: DOUBLE FAULT` and the exception stack frame](qemu-catch-double-fault.png)
動きました!ここで何が起きているかというと、
1. CPUが`0xdeadbeef`に書き込みを試みページフォルトを起こします。
2. 以前と同様に、CPUはIDT中の対応するエントリを見にいきますが、ハンドラ関数が定義されていないことを発見し、結果、ダブルフォルトが起きます。
3. 今回はダブルフォルトハンドラが指定されているので、CPUはそれを適切に呼び出せます。
CPUはダブルフォルトハンドラを呼べるようになったので、トリプルフォルトとブートループはもう起こりません。
ここまでは簡単です。ではなぜこの例外のために丸々一つの記事を用意したのでしょうか?実は、私達は**ほとんどの**ダブルフォルトをキャッチすることはできますが、このアプローチでは十分でないケースがいくつか存在するのです。
## ダブルフォルトの原因
特別なケースを見ていく前に、ダブルフォルトの正確な原因を知る必要があります。ここまで、私達はとてもあいまいな定義を使ってきました。
> ダブルフォルトとはCPUが例外ハンドラを呼び出すことに失敗したときに起きる特別な例外です。
**「呼び出すことに失敗する」** とは正確には何を意味するのでしょうか?ハンドラが存在しない?ハンドラが[スワップアウト]された?また、ハンドラ自身が例外を発生させたらどうなるのでしょうか?
[スワップアウト]: http://pages.cs.wisc.edu/~remzi/OSTEP/vm-beyondphys.pdf
例えば以下のようなことが起こるとどうなるでしょう?
1. ブレークポイント例外が発生したが、対応するハンドラがスワップアウトされていたら?
2. ページフォルトが発生したが、ページフォルトハンドラがスワップアウトされていたら?
3. ゼロ除算ハンドラがブレークポイント例外を起こしたが、ブレークポイントハンドラがスワップアウトされていたら?
4. カーネルがスタックをオーバーフローさせて**ガードページ**にヒットしたら?
幸いにもAMD64のマニュアル[PDF][AMD64 manual]には正確な定義が書かれています8.2.9章)。それによると「ダブルフォルト例外は直前の(一度目の)例外ハンドラの処理中に二度目の例外が発生したとき**起きうる** can occur」と書かれています。**起きうる**というのが重要で、とても特別な例外の組み合わせでのみダブルフォルトとなります。この組み合わせは以下のようになっています。
最初の例外 | 二度目の例外
----------------|-----------------
[ゼロ除算],<br>[無効TSS],<br>[セグメント不在],<br>[スタックセグメントフォルト],<br>[一般保護違反] | [無効TSS],<br>[セグメント不在],<br>[スタックセグメントフォルト],<br>[一般保護違反]
[ページフォルト] | [ページフォルト],<br>[無効TSS],<br>[セグメント不在],<br>[スタックセグメントフォルト],<br>[一般保護違反]
[ゼロ除算]: https://wiki.osdev.org/Exceptions#Division_Error
[無効TSS]: https://wiki.osdev.org/Exceptions#Invalid_TSS
[セグメント不在]: https://wiki.osdev.org/Exceptions#Segment_Not_Present
[スタックセグメントフォルト]: https://wiki.osdev.org/Exceptions#Stack-Segment_Fault
[一般保護違反]: https://wiki.osdev.org/Exceptions#General_Protection_Fault
[ページフォルト]: https://wiki.osdev.org/Exceptions#Page_Fault
[AMD64 manual]: https://www.amd.com/system/files/TechDocs/24593.pdf
例えばゼロ除算例外に続いてページフォルトが起きた場合は問題ありません(ページフォルトハンドラが呼び出される)が、ゼロ除算例外に続いて一般保護違反が起きた場合はダブルフォルトが発生します。
この表を見れば、先程の質問のうち最初の3つに答えることができます:
1. ブレークポイント例外が発生して、対応するハンドラ関数がスワップアウトされている場合、**ページフォルト**が発生して**ページフォルトハンドラ**が呼び出される
2. ページフォルトが発生してページフォルトハンドラがスワップアウトされている場合、**ダブルフォルト**が発生して**ダブルフォルトハンドラ**が呼び出されます。
3. ゼロ除算ハンドラがブレークポイント例外を発生させた場合、CPUはブレークポイントハンドラを呼び出そうとします。もしブレークポイントハンドラがスワップアウトされている場合、**ページフォルト**が発生して**ページフォルトハンドラ**が呼び出されます。
実際、IDTにハンドラ関数が指定されていない例外のケースでもこの体系に従っています。つまり、例外が発生したとき、CPUは対応するIDTエントリを読み込みにいきます。このエントリは0であり正しいIDTエントリではないので、**一般保護違反**が発生します。私達は一般保護違反のハンドラも定義していないので、新たな一般保護違反が発生します。表によるとこれはダブルフォルトを起こします。
### カーネルスタックオーバーフロー
4つ目の質問を見てみましょう。
> カーネルがスタックをオーバーフローさせてガードページにヒットしたら?
ガードページはスタックの底にある特別なメモリページで、これによってスタックオーバーフローを検出することができます。このページはどの物理メモリにもマップされていないので、アクセスすることで警告なく他のメモリを破壊する代わりにページフォルトが発生します。ブートローダーはカーネルスタックのためにガードページをセットアップするので、スタックオーバーフローは**ページフォルト**を発生させることになります。
ページフォルトが起きるとCPUはIDT内のページフォルトハンドラを探しにいき、[割り込みスタックフレーム](訳注: 翻訳当時、リンク先未訳)をスタック上にプッシュしようと試みます。しかし、このスタックポインタは存在しないガードページを指しています。結果、二度目のページフォルトが発生して、ダブルフォルトが起きます(上の表によれば)。
[割り込みスタックフレーム]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-stack-frame
そして、ここでCPUは**ダブルフォルトハンドラ**を呼びにいきます。しかし、ダブルフォルト例外においてもCPUは例外スタックフレームをプッシュしようと試みます。スタックポインタはまだガードページを指しているので、**三度目の**ページフォルトが起きて、**トリプルフォルト**を発生させシステムは再起動します。そのため、私達の今のダブルフォルトハンドラではこの場合でのトリプルフォルトを避けることができません。
実際にやってみましょう。カーネルスタックオーバーフローは無限に再帰する関数を呼び出すことによって簡単に引き起こせます:
```rust
// in src/main.rs
#[no_mangle] // この関数の名前修飾をしない
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
fn stack_overflow() {
stack_overflow(); // 再帰呼び出しのために、リターンアドレスがプッシュされる
}
// スタックオーバーフローを起こす
stack_overflow();
[] // test_main(), println(…), and loop {}
}
```
これをQEMUで試すと、再びブートループに入るのがわかります。
では、私達はどうすればこの問題を避けられるでしょうか例外スタックフレームをプッシュすることは、CPU自身が行ってしまうので、省略できません。つまりどうにかしてダブルフォルト例外が発生したときスタックが常に正常であることを保証する必要があります。幸いにもx86_64アーキテクチャにはこの問題の解決策があります。
## スタックを切り替える
x86_64アーキテクチャは例外発生時に予め定義されている既知の正常なスタックに切り替えることができます。この切り替えはハードウェアレベルで発生するので、CPUが例外スタックフレームをプッシュする前に行うことができます。
切り替えの仕組みは**割り込みスタックテーブル**IST: Interrupt Stack Tableとして実装されています。ISTはつの既知の正常なポインタのテーブルです。Rust風の疑似コードで表すとこのようになります
```rust
struct InterruptStackTable {
stack_pointers: [Option<StackPointer>; 7],
}
```
各例外ハンドラに対して、私達は対応する[IDTエントリ](訳注: 翻訳当時、リンク先未訳)の`stack_pointers`フィールドを通してISTからスタックを選ぶことができます。例えば、IST中の最初のスタックをダブルフォルトハンドラのために使うことができます。そうすると、CPUがダブルフォルトが発生したときは必ず、このスタックに自動的に切り替えを行います。この切り替えは何かがプッシュされる前に起きるので、トリプルフォルトを防ぐことになります。
[IDTエントリ]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
### ISTとTSS
割り込みスタックテーブルIST**[タスクステートセグメント]**TSSというレガシーな構造体の一部です。TSSはかつては様々な32ビットモードでのタスクに関する情報プロセッサのレジスタの状態を保持していて、例えば[ハードウェアコンテキストスイッチング]に使われていました。しかし、ハードウェアコンテキストスイッチングは64ビットモードではサポートされなくなり、TSSのフォーマットは完全に変わりました。
[タスクステートセグメント]: https://ja.wikipedia.org/wiki/Task_state_segment
[ハードウェアコンテキストスイッチング]: https://wiki.osdev.org/Context_Switching#Hardware_Context_Switching
x86_64ではTSSはタスク固有の情報は全く持たなくなりました。代わりに、2つのスタックテーブルISTがその1つを持つようになりました。唯一32ビットと64ビットのTSSで共通のフィールドは[I/Oポート許可ビットマップ]へのポインタのみです。
[I/Oポート許可ビットマップ]: https://ja.wikipedia.org/wiki/Task_state_segment#I/O許可ビットマップ
64ビットのTSSは下記のようなフォーマットです
フィールド | 型
------ | ----------------
<span style="opacity: 0.5">(予約済み)</span> | `u32`
特権スタックテーブル | `[u64; 3]`
<span style="opacity: 0.5">(予約済み)</span> | `u64`
割り込みスタックテーブル | `[u64; 7]`
<span style="opacity: 0.5">(予約済み)</span> | `u64`
<span style="opacity: 0.5">(予約済み)</span> | `u16`
I/Oマップベースアドレス | `u16`
**特権スタックテーブル**は特権レベルが変わった際にCPUが使用します。例えば、CPUがユーザーモード特権レベル3の時に例外が発生した場合、CPUは通常例外ハンドラを呼び出す前にカーネルモード特権レベル0に切り替わります。この場合、CPUは特権レベルスタックテーブルの0番目のスタックに切り替わります。ユーザーモードについてはまだ実装してないため、このテープルはとりあえず無視しておきましょう。
### TSSをつくる
割り込みスタックテーブルにダブルフォルト用のスタックを含めた新しいTSSをつくってみましょう。そのためにはTSS構造体が必要です。幸いにも、すでに`x86_64`クレートに[`TaskStateSegment`構造体]が含まれているので、これを使っていきます。
[`TaskStateSegment`構造体]: https://docs.rs/x86_64/0.14.2/x86_64/structures/tss/struct.TaskStateSegment.html
新しい`gdt`モジュール内でTSSをつくります名前の意味は後でわかるでしょう
```rust
// in src/lib.rs
pub mod gdt;
// in src/gdt.rs
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 5;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end
};
tss
};
}
```
Rustの定数評価機はこの初期化をコンパイル時に行うことがまだできないので`lazy_static`を使います。ここでは0番目のISTエントリをダブルフォルト用のスタックとして定義します他のISTのインデックスでも動くでしょう。そして、ダブルフォルト用スタックの先頭アドレスを0番目のエントリに書き込みます。先頭アドレスを書き込むのはx86のスタックは下、つまり高いアドレスから低いアドレスに向かって伸びていくからです。
私達はまだメモリ管理を実装していません。そのため、新しいスタックを確保する適切な方法がありません。その代わり今回は、スタックのストレージとして`static mut`な配列を使います。コンパイラが変更可能な静的変数がアクセスされるとき競合がないことを保証できないため`unsafe`が必要となります。これが不変の`static`ではなく`static mut`であることは重要です。そうでなければブートローダーはこれをリードオンリーのページにマップしてしまうからです。私達は後の記事でこの部分を適切なスタック確保処理に置き換えます。そうすればこの部分での`unsafe`は必要なくなります。
ちなみに、このダブルフォルトスタックはスタックオーバーフローに対する保護をするガードページを持ちません。つまり、スタックオーバーフローがスタックより下のメモリを破壊するかもしれないので、私達はダブルフォルトハンドラ内でスタックを多用すべきではないということです。
#### TSSを読み込む
新しいTSSをつくったので、CPUにそれを使うように教える方法が必要です。残念ながら、これはちょっと面倒くさいです。なぜならTSSは歴史的な理由でセグメンテーションシステムを使うためです。テーブルを直接読み込むのではなく、新しいセグメントディスクリプタを[グローバルディスクリプタテーブル]GDT: Global Descriptor Tableに追加する必要があります。そうすると各自のGDTインデックスで[`ltr`命令]を呼び出すことで私達のTSSを読み込むことができます。
[グローバルディスクリプタテーブル]: https://web.archive.org/web/20190217233448/https://www.flingos.co.uk/docs/reference/Global-Descriptor-Table/
[`ltr`命令]: https://www.felixcloutier.com/x86/ltr
### グローバルディスクリプタテーブル
グローバルディスクリプタテーブルGDTはページングがデファクトスタンダードになる以前は、[メモリセグメンテーション]のため使われていた古い仕組みです。カーネル・ユーザーモードの設定やTSSの読み込みなど、様々なことを行うために64ビットモードでも未だに必要です。
[メモリセグメンテーション]: https://ja.wikipedia.org/wiki/セグメント方式
GDTはプログラムの**セグメント**を含む構造です。ページングが標準になる以前に、プログラム同士を独立させるためにより古いアーキテクチャで使われていました。セグメンテーションに関するより詳しい情報は無料の[「Three Easy Pieces」]という本の同じ名前の章を見てください。セグメンテーションは64ビットモードではもうサポートされていませんが、GDTはまだ存在しています。GDTはカーネル空間とユーザー空間の切り替えと、TSS構造体の読み込みという主に2つのことに使われています。
[「Three Easy Pieces」]: http://pages.cs.wisc.edu/~remzi/OSTEP/
#### GDTをつくる
`TSS`の静的変数のセグメントを含む静的`GDT`をつくりましょう:
```rust
// in src/gdt.rs
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
lazy_static! {
static ref GDT: GlobalDescriptorTable = {
let mut gdt = GlobalDescriptorTable::new();
gdt.add_entry(Descriptor::kernel_code_segment());
gdt.add_entry(Descriptor::tss_segment(&TSS));
gdt
};
}
```
先に紹介したコードと同様に、再び`lazy_static`を使います。コードセグメントとTSSセグメントを持つ新しいGDTを作成します。
#### GDTを読み込む
GDTを読み込むために新しく`gdt::init`関数をつくり、これを`init`関数から呼び出します:
```rust
// in src/gdt.rs
pub fn init() {
GDT.load();
}
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
}
```
これでGDTが読み込まれます`_start`関数は`init`を呼び出すため)が、これではまだスタックオーバーフローでブートループが起きてしまいます。
### 最後のステップ
問題はGDTセグメントとTSSレジスタが古いGDTからの値を含んでいるため、GDTセグメントがまだ有効になっていないことです。ダブルフォルト用のIDTエントリが新しいスタックを使うように変更する必要もあります。
まとめると、私達は次のようなことをする必要があります:
1. **コードセグメントレジスタを再読み込みする**GDTを変更したので、コードセグメントレジスタ`cs`を再読み込みする必要があります。これは、古いセグメントセレクタが異なるGDTディスクリプタTSSディスクリプタを指す可能性があるためです。
2. **TSSをロードする**TSSセレクタを含むGDTをロードしましたが、CPUにこのTSSを使うよう教えてあげる必要があります。
3. **IDTエントリを更新する**TSSがロードされると同時に、CPUは正常な割り込みスタックテーブルISTへアクセスできるようになります。そうしたら、ダブルフォルトIDTエントリを変更することで、CPUに新しいダブルフォルトスタックを使うよう教えてあげることができます。
最初の2つのステップのために、私達は`gdt::init`関数の中で`code_selector``tss_selector`変数にアクセスする必要があります。これは、その変数たちを新しい`Selectors`構造体を使い静的変数にすることで実装できます:
```rust
// in src/gdt.rs
use x86_64::structures::gdt::SegmentSelector;
lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}
struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}
```
これで私達は`cs`セグメントレジスタを再読み込みし`TSS`を読み込むためにセレクタを使うことができます:
```rust
// in src/gdt.rs
pub fn init() {
use x86_64::instructions::segmentation::set_cs;
use x86_64::instructions::tables::load_tss;
GDT.0.load();
unsafe {
set_cs(GDT.1.code_selector);
load_tss(GDT.1.tss_selector);
}
}
```
[`set_cs`]を使ってコードセグメントレジスタを再読み込みして、[`load_tss`]を使ってTSSを読み込んでいます。これらの関数は`unsafe`とマークされているので、呼び出すには`unsafe`ブロックが必要です。`unsafe`なのは、不正なセレクタを読み込むことでメモリ安全性を壊す可能性があるからです。
[`set_cs`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/segmentation/fn.set_cs.html
[`load_tss`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tables/fn.load_tss.html
これで正常なTSSと割り込みスタックテーブルを読み込んだので、私達はIDT内のダブルフォルトハンドラにスタックインデックスをセットすることができます
```rust
// in src/interrupts.rs
use crate::gdt;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
unsafe {
idt.double_fault.set_handler_fn(double_fault_handler)
.set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new
}
idt
};
}
```
`set_stack_index`メソッドは呼び出し側が、使われているインデックスが正しいものであり、かつ他の例外で使われていないかを確かめる必要があるため、`unsafe`です。
これで全部です。CPUはダブルフォルトが発生したら常にダブルフォルトスタックに切り替えるでしょう。よって、私達はカーネルスタックオーバーフローを含む**すべての**ダブルフォルトをキャッチすることができます。
![QEMU printing `EXCEPTION: DOUBLE FAULT` and a dump of the exception stack frame](qemu-double-fault-on-stack-overflow.png)
これからはトリプルフォルトを見ることは二度とないでしょう。これらダブルフォルトのための実装を誤って壊していないことを保証するために、テストを追加しましょう。
## スタックオーバーフローテスト
新しい`gdt`モジュールをテストしダブルフォルトハンドラがスタックオーバーフローで正しく呼ばれることを保証するために、結合テストを追加します。ここでの考えは、テスト関数内でダブルフォルトを引き起こしダブルフォルトハンドラが呼び出されていることを確かめる、というものです。
最小の骨組みから始めましょう:
```rust
// in tests/stack_overflow.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
`panic_handler`のテストと同様、テストは[テストハーネスなし]で実行されます。理由は私達はダブルフォルト後に実行を続けることができず、2つ以上のテストは意味をなさないためです。テストハーネスを無効にするために、以下を`Cargo.toml`に追加します:
```toml
# in Cargo.toml
[[test]]
name = "stack_overflow"
harness = false
```
[テストハーネスなし]: @/edition-2/posts/04-testing/index.ja.md#hanesu-harness-nonaitesuto
これで`cargo test --test stack_overflow`でのコンパイルは成功するでしょう。`unimplemented`マクロがパニックを起こすため、テストはもちろん失敗します。
### `_start`を実装する
`_start`関数の実装はこのようになります:
```rust
// in tests/stack_overflow.rs
use blog_os::serial_print;
#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_print!("stack_overflow::stack_overflow...\t");
blog_os::gdt::init();
init_test_idt();
// スタックオーバーフローを起こす
stack_overflow();
panic!("Execution continued after stack overflow");
}
#[allow(unconditional_recursion)]
fn stack_overflow() {
stack_overflow(); // 再帰のたびにリターンアドレスがプッシュされる
volatile::Volatile::new(0).read(); // 末尾最適化を防ぐ
}
```
新しいGDTを初期化するために`gdt::init`関数を呼びます。そして`interrupts::init_idt`関数を呼び出す代わりに、すぐ後で説明する`init_test_idt`関数を呼びます。なぜなら、私達はパニックの代わりに`exit_qemu(QemuExitCode::Success)`を実行するカスタムしたダブルフォルトハンドラを登録したいためです。
`stack_overflow`関数は`main.rs`の中にある関数とほとんど同じです。唯一の違いは[**末尾呼び出し最適化**]と呼ばれるコンパイラの最適化を防ぐために[`Volatile`]タイプを使って関数の末尾で追加の[volatile]読み込みを行っていることです。この最適化の特徴として、コンパイラが、最後の文が再帰関数呼び出しである関数を通常のループに変換できるようになる、というものがあります。その結果として、追加のスタックフレームが関数呼び出しではつくられず、スタックの使用量が変わらないままとなります。
[volatile]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
[`Volatile`]: https://docs.rs/volatile/0.2.6/volatile/struct.Volatile.html
[**末尾呼び出し最適化**]: https://ja.wikipedia.org/wiki/末尾再帰#末尾呼出し最適化
しかし、ここではスタックオーバーフローを起こしたいので、コンパイラに削除されない、ダミーのvolatile読み込み文を関数の末尾に追加します。その結果、関数は**末尾再帰**ではなくなり、ループへの変換は防がれます。更に関数が無限に再帰することに対するコンパイラの警告をなくすために`allow(unconditional_recursion)`属性を追加します。
### IDTのテスト
上で述べたように、テストはカスタムしたダブルフォルトハンドラを含む専用のIDTが必要です。実装はこのようになります
```rust
// in tests/stack_overflow.rs
use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;
lazy_static! {
static ref TEST_IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.double_fault
.set_handler_fn(test_double_fault_handler)
.set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
}
idt
};
}
pub fn init_test_idt() {
TEST_IDT.load();
}
```
実装は`interrupts.rs`内の通常のIDTと非常に似ています。通常のIDT同様、分離されたスタックに切り替えるようダブルフォルトハンドラ用のISTにスタックインデックスをセットします。`init_test_idt`関数は`load`メソッドによりCPU上にIDTを読み込みます。
### ダブルフォルトハンドラ
唯一欠けているのはダブルフォルトハンドラです。このようになります:
```rust
// in tests/stack_overflow.rs
use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;
extern "x86-interrupt" fn test_double_fault_handler(
_stack_frame: InterruptStackFrame,
_error_code: u64,
) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
```
ダブルフォルトハンドラが呼ばれるとき、私達はQEMUを正常な終了コードで終了し、テストを成功とマークします。結合テストは完全に分けられた実行ファイルなので、私達はテストファイルの先頭で`#![feature(abi_x86_interrupt)]`属性を再びセットする必要があります。
これで私達は`cargo test --test stack_overflow`(もしくは全部のテストを走らせるよう`cargo test`)でテストを走らせることができます。期待していたとおり、`stack_overflow... [ok]`とコンソールに出力されるのがわかります。`set_stack_index`の行をコメントアウトすると、テストは失敗するでしょう。
## まとめ
この記事では私達はダブルフォルトが何であるかとどういう条件下で発生するかを学びました。エラーメッセージを出力する基本的なダブルフォルトハンドラと、そのための結合テストを追加しました。
また、私達はスタックオーバーフロー下でも動くよう、ダブルフォルト発生時にハードウェアがサポートするスタック切り替えを行うようにしました。実装していく中で、古いアーキテクチャでのセグメンテーションで使われていたタスクステートセグメントTSS、割り込みスタックテーブルIST、グローバルディスクリプタテーブルGDTについても学びました。
## 次は?
次の記事ではタイマーやキーボード、ネットワークコントローラのような、外部デバイスからの割り込みをどのように処理するかを説明します。これらのハードウェア割り込みは例外によく似ています。例えば、これらもIDTからディスパッチされます。しかしながら、例外とは違い、それらはCPU上で直接発生するものではありません。代わりに、**割り込みコントローラ**がこれらの割り込みを集めて、優先度によってそれらをCPUに送ります。次回、私達は[Intel 8259]PIC割り込みコントローラを調べ、どのようにキーボードのサポートを実装するかを学びます。
[Intel 8259]: https://ja.wikipedia.org/wiki/Intel_8259

View File

@@ -1,558 +0,0 @@
+++
title = "더블 폴트 (Double Fault)"
weight = 6
path = "ko/double-fault-exceptions"
date = 2018-06-18
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "a108367d712ef97c28e8e4c1a22da4697ba6e6cd"
# GitHub usernames of the people that translated this post
translators = ["JOE1994"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["dalinaum"]
+++
이번 글에서는 CPU가 예외 처리 함수를 호출하는 데에 실패할 때 발생하는 더블 폴트 (double fault) 예외에 대해 자세히 다룹니다. 더블 폴트 예외를 처리함으로써 시스템 재부팅을 발생시키는 치명적인 _트리플 폴트 (triple fault)_ 예외를 피할 수 있습니다. 트리플 폴트가 발생할 수 있는 모든 경우에 대비하기 위해 _Interrupt Stack Table_ 을 만들고 별도의 커널 스택에서 더블 폴트를 처리할 것입니다.
<!-- more -->
이 블로그는 [GitHub 저장소][GitHub]에서 오픈 소스로 개발되고 있으니, 문제나 문의사항이 있다면 저장소의 'Issue' 기능을 이용해 제보해주세요. [페이지 맨 아래][at the bottom]에 댓글을 남기실 수도 있습니다. 이 포스트와 관련된 모든 소스 코드는 저장소의 [`post-06 브랜치`][post branch]에서 확인하실 수 있습니다.
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-06
<!-- toc -->
## 더블 폴트 (Double Fault)란 무엇일까요?
간단히 말하면, 더블 폴트는 CPU가 예외 처리 함수를 호출하는 것에 실패했을 때 발생하는 예외입니다. 예를 들면 페이지 폴트가 발생했는데 [인터럽트 서술자 테이블 (Interrupt Descriptor Table; IDT)][IDT] 에 등록된 페이지 폴트 처리 함수가 없을 때 더블 폴트가 예외가 발생합니다. 비유한다면 C++의 `catch(..)`문이나 Java 및 C#의 `catch(Exception e)`문처럼 모든 종류의 예외를 처리할 수 있다는 점에서 유사합니다.
[IDT]: @/edition-2/posts/05-cpu-exceptions/index.ko.md#the-interrupt-descriptor-table
더블 폴트는 다른 예외들과 다를 게 없습니다. IDT 내에서 배정된 벡터 인덱스(`8`)가 있고, IDT에 해당 예외를 처리할 일반 함수를 정의할 수 있습니다. 더블 폴트 처리 함수를 제공하는 것은 매우 중요한데, 더블 폴트가 처리되지 않으면 치명적인 _트리플 폴트_ 가 발생하기 때문입니다. 트리플 폴트를 처리하는 것은 불가능해서 대부분의 하드웨어는 시스템을 리셋하는 방식으로 대응합니다.
### 더블 폴트 일으키기
예외 처리 함수가 등록되지 않은 예외를 발생시켜 더블 폴트를 일으켜 보겠습니다.
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// 페이지 폴트 일으키기
unsafe {
*(0xdeadbeef as *mut u8) = 42;
};
// 이전과 동일
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
`unsafe` 키워드를 사용해 유효하지 않은 메모리 주소 `0xdeadbeef`에 값을 씁니다. 페이지 테이블에서 해당 가상 주소는 실제 물리 주소에 매핑되지 않았기에 페이지 폴트가 발생합니다. 아직 우리가 [IDT]에 페이지 폴트 처리 함수를 등록하지 않았기 때문에 이어서 더블 폴트가 발생합니다.
이제 커널을 실행시키면 커널이 무한히 부팅하는 루프에 갇히는 것을 확인하실 수 있습니다. 커널이 루프에 갇히는 이유는 아래와 같습니다.
1. CPU가 메모리 주소 `0xdeadbeef`에 값을 쓰려고 시도한 것 때문에 페이지 폴트가 발생합니다.
2. CPU는 IDT에서 페이지 폴트에 대응하는 엔트리를 확인하지만, 페이지 폴트 처리 함수가 등록되어 있지 않습니다. 호출할 수 있는 페이지 폴트 처리 함수가 없어 더블 폴트가 발생합니다.
3. CPU는 IDT에서 더블 폴트에 대응하는 엔트리를 확인하지만, 더블 폴트 처리 함수가 등록되어 있지 않습니다. 이후 _트리플 폴트_ 가 발생합니다.
4. 트리플 폴트는 치명적입니다. 다른 실제 하드웨어들처럼 QEMU 또한 시스템을 리셋합니다.
이런 상황에서 트리플 폴트 발생을 막으려면 페이지 폴트 또는 더블 폴트의 처리 함수를 등록해야 합니다. 어떤 경우에서든 트리플 폴트만은 막아야 하므로, 처리되지 않은 예외가 있을 때 호출되는 더블 폴트의 처리 함수부터 먼저 작성하겠습니다.
## 더블 폴트 처리 함수
더블 폴트도 일반적인 예외로서 오류 코드를 가집니다. 따라서 더블 폴트 처리 함수를 작성할 때 이전에 작성한 breakpoint 예외 처리 함수와 비슷하게 작성할 수 있습니다.
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler); // 새롭게 추가함
idt
};
}
// 새롭게 추가함
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame, _error_code: u64) -> !
{
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}
```
우리가 작성한 더블 폴트 처리 함수는 짧은 오류 메시지와 함께 예외 스택 프레임의 정보를 출력합니다. 더블 폴트 처리 함수의 오류 코드가 0인 것은 이미 아는 사실이니 굳이 출력할 필요가 없습니다. breakpoint 예외 처리 함수와 비교해 하나 다른 점은 더블 폴트 처리 함수가 [발산하는][_diverging_] 함수라는 것인데, 그 이유는 더블 폴트로부터 반환하는 것을 `x86_64` 아키텍처에서 허용하지 않기 때문입니다.
[_diverging_]: https://doc.rust-lang.org/stable/rust-by-example/fn/diverging.html
이제 커널을 실행시키면 더블 폴트 처리 함수가 호출되는 것을 확인하실 수 있습니다.
![QEMU printing `EXCEPTION: DOUBLE FAULT` and the exception stack frame](qemu-catch-double-fault.png)
성공입니다! 어떤 일들이 일어났는지 단계별로 살펴보겠습니다.
1. CPU가 메모리 주소 `0xdeadbeef`에 값을 적으려 하고, 그 결과 페이지 폴트가 발생합니다.
2. 이전처럼 CPU는 IDT에서 페이지 폴트에 대응하는 엔트리를 확인하지만, 등록된 처리 함수가 없음을 확인합니다. 그 결과 더블 폴트가 발생합니다.
3. CPU의 제어 흐름이 등록된 더블 폴트 처리 함수로 점프합니다.
CPU가 더블 폴트 처리 함수를 호출할 수 있기에, 트리플 폴트와 무한 재부팅 루프는 더 이상 발생하지 않습니다.
별로 어렵지 않군요! 그럼에도 이 주제 하나에 이 글 전체를 할애한 이유가 궁금하신가요? 사실, 현재 우리는 _대부분의_ 더블 폴트를 처리할 수는 있지만, 현재의 커널 구현으로는 더블 폴트를 처리하지 못하는 특수한 경우들이 아직 남아 있습니다.
## 더블 폴트의 원인들
특수한 경우들을 살펴보기 전에, 우선 더블 폴트가 일어나는 엄밀한 원인에 대해 파악해야 합니다. 본문의 윗부분에서는 더블 폴트를 설명할 때 다소 애매하고 느슨한 정의를 사용했습니다.
> 더블 폴트는 CPU가 예외 처리 함수를 호출하는 것에 실패했을 때 발생하는 예외입니다.
_“예외 처리 함수를 호출하는 것에 실패했을 때”_ 라는 게 정확히 무슨 뜻일까요? 예외 처리 함수가 등록되어 있지 않아 호출에 실패했다? 예외 처리 함수가 [스왑-아웃][swapped out] 되어 있어 호출에 실패했다? 그리고 예외 처리 함수 자체가 다시 예외를 발생시키면 어떻게 될까요?
[swapped out]: http://pages.cs.wisc.edu/~remzi/OSTEP/vm-beyondphys.pdf
예를 들어, 아래의 각각의 상황들을 가정했을 때 무슨 일이 일어날지 고민해 봅시다.
1. breakpoint 예외가 발생한 시점에 breakpoint 예외 처리 함수가 스왑-아웃 되어 있는 경우?
2. 페이지 폴트가 발생한 시점에 페이지 폴트 처리 함수가 스왓-아웃 되어 있는 경우?
3. divide-by-zero 예외 처리 함수가 breakpoint 예외를 발생시킨 시점에 breakpoint 예외 처리 함수가 스왑-아웃 되어 있는 경우?
4. 커널이 스택 오버 플로우를 일으켜 _보호 페이지 (guard page)_ 에 접근하는 경우?
다행히 AMD64 매뉴얼 ([PDF][AMD64 manual])에서 더블 폴트의 명확한 정의를 제시합니다 (매뉴얼 섹션 8.2.9 참조). 매뉴얼의 정의에 따르면, “더블 폴트 예외는 1번째 발생한 예외를 처리하는 도중 2번째 예외가 발생한 경우에 _발생할 수 있다_” 합니다. 여기서 _“발생할 수 있다”_ 라는 표현이 중요한데, 더블 폴트는 아래의 표에서 보이는 것처럼 특수한 조합의 예외들이 순서대로 일어났을 때에만 발생합니다.
1번째 발생한 예외 | 2번째 발생한 예외
----------------|-----------------
[Divide-by-zero],<br>[Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault] | [Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault]
[Page Fault] | [Page Fault],<br>[Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault]
[Divide-by-zero]: https://wiki.osdev.org/Exceptions#Division_Error
[Invalid TSS]: https://wiki.osdev.org/Exceptions#Invalid_TSS
[Segment Not Present]: https://wiki.osdev.org/Exceptions#Segment_Not_Present
[Stack-Segment Fault]: https://wiki.osdev.org/Exceptions#Stack-Segment_Fault
[General Protection Fault]: https://wiki.osdev.org/Exceptions#General_Protection_Fault
[Page Fault]: https://wiki.osdev.org/Exceptions#Page_Fault
[AMD64 manual]: https://www.amd.com/system/files/TechDocs/24593.pdf
예를 들면 divide-by-zero 예외 뒤에 페이지 폴트가 발생하는 것은 괜찮지만 (페이지 폴트 처리 함수가 호출됨), divide-by-zero 예외 뒤에 general-protection fault 예외가 발생하면 더블 폴트가 발생합니다.
위 테이블을 이용하면 위에서 했던 질문 중 첫 3개에 대해 대답할 수 있습니다.
1. breakpoint 예외가 발생한 시점에 해당 예외 처리 함수가 스왑-아웃 되어 있는 경우, _페이지 폴트_ 가 발생하고 _페이지 폴트 처리 함수_ 가 호출됩니다.
2. 페이지 폴트가 발생한 시점에 페이지 폴트 처리 함수가 스왑-아웃 되어 있는 경우, _더블 폴트_ 가 발생하고 _더블 폴트 처리 함수_ 가 호출됩니다.
3. divide-by-zero 예외 처리 함수가 breakpoint 예외를 일으키는 경우, CPU가 breakpoint 예외 처리 함수의 호출을 시도합니다. breakpoint 예외 처리 함수가 스왑-아웃 되어 있는 경우, _페이지 폴트_ 가 발생하고 _페이지 폴트 처리 함수_ 가 호출됩니다.
사실 임의의 예외에 대한 처리 함수가 IDT에 없다는 것만으로 더블 폴트가 발생하는 것이 아닙니다. 예외가 발생하면 CPU는 그 예외에 대응하는 IDT 엔트리를 참조합니다. 해당 엔트리 값이 0인 경우 (= 예외 처리 함수가 등록되어 있지 않음), _general protection fault_ 예외가 발생합니다. 우리는 해당 예외를 처리할 함수를 등록하지 않았기 때문에, 새로운 general protection fault 예외가 또 발생합니다. general protection fault가 이어서 2번 일어났으니, 위 테이블에 따라 더블 폴트가 발생합니다.
### 커널 스택 오버플로우
이제 위의 질문들 중 마지막 4번째 질문을 살펴보겠습니다.
> 커널이 스택 오버 플로우를 일으켜 _보호 페이지 (guard page)_ 에 접근하는 경우, 무슨 일이 일어날까요?
보호 페이지는 스택의 맨 아래에 위치하면서 스택 오버플로우를 감지하는 특별한 메모리 페이지입니다. 해당 페이지는 어떤 물리 프레임에도 매핑되지 않으며, CPU가 해당 페이지에 접근하면 물리 메모리에 접근하는 대신 페이지 폴트가 발생합니다. 부트로더가 커널 스택의 보호 페이지를 초기화하며, 이후 커널 스택 오버플로우가 발생하면 _페이지 폴트_ 가 발생합니다.
페이지 폴트가 발생하면 CPU는 IDT에서 페이지 폴트 처리 함수를 찾고 스택에 [인터럽트 스택 프레임 (interrupt stack frame)][interrupt stack frame]을 push 하려고 합니다. 하지만 현재의 스택 포인터는 물리 프레임이 매핑되지 않은 보호 페이지를 가리키고 있습니다. 따라서 2번째 페이지 폴트가 발생하고, 그 결과 더블 폴트가 발생합니다 (위 테이블 참조).
[interrupt stack frame]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-stack-frame
CPU는 이제 _더블 폴트 처리 함수_ 를 호출하려고 시도합니다. 하지만, 더블 폴트 발생 시 CPU는 또 예외 스택 프레임 (= 인터럽트 스택 프레임)을 스택에 push하려고 합니다. 스택 포인터는 여전히 보호 페이지를 가리키고, 따라서 _3번째_ 페이지 폴트 발생 후 _트리플 폴트_ 가 발생하고 시스템이 재부팅 됩니다. 우리가 지금 가진 더블 폴트 처리 함수로는 이 상황에서 트리플 폴트를 막을 수 없습니다.
역시 백문이 불여일견이죠! 무한 재귀 함수를 호출해 손쉽게 커널 스택오버플로우를 일으켜 봅시다.
```rust
// in src/main.rs
#[no_mangle] // 이 함수의 이름을 mangle하지 않습니다
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
fn stack_overflow() {
stack_overflow(); // 재귀 호출할 때마다 스택에 반환 주소를 push 합니다
}
// 스택 오버플로우 일으키기
stack_overflow();
[] // test_main(), println(…), and loop {}
}
```
이 코드를 QEMU에서 실행하면 시스템이 또 무한 재부팅 루프에 갇히는 것을 확인할 수 있습니다.
이 문제를 어떻게 피할 수 있을까요? CPU 하드웨어가 예외 스택 프레임을 push 하는 것이라서, 커널 코드를 통해 스택 프레임의 push 과정을 생략할 수는 없습니다. 그래서 더블 폴트가 발생한 시점에는 늘 커널 스택이 유효하도록 보장할 수 있는 방법을 찾아야 합니다. 다행히도, x86_64 아키텍처는 이 문제에 대한 해답을 가지고 있습니다.
## 스택 교체하기
x86_64 아키텍처는 예외 발생 시 스택을 미리 지정한 다른 안전한 스택으로 교체하는 것이 가능합니다. 이러한 스택 교체는 하드웨어 단에서 일어나고, 따라서 CPU가 예외 스택 프레임을 스택에 push 하기 전에 스택을 교체하는 것이 가능합니다.
이러한 스택 교체는 _인터럽트 스택 테이블 (Interrupt Stack Table; IST)_ 을 사용해 진행됩니다. IST는 안전한 것으로 알려진 7개의 다른 스택들의 주소를 저장하는 테이블입니다. IST의 구조를 Rust 코드 형식으로 표현하자면 아래와 같습니다.
```rust
struct InterruptStackTable {
stack_pointers: [Option<StackPointer>; 7],
}
```
각 예외 처리 함수는 [IDT 엔트리][IDT entry]의 `stack_pointers` 필드를 통해 IST의 스택 중 하나를 사용하도록 선택할 수 있습니다. 예를 들어, 우리의 더블 폴트 처리 함수가 IST의 1번째 스택을 사용하도록 설정할 수 있습니다. 그 후에는 더블 폴트가 발생할 때마다 CPU가 스택을 IST의 1번째 스택으로 교체합니다. 스택에 새로운 데이터가 push 되기 전에 스택 교체가 이뤄지기 때문에 트리플 폴트를 피할 수 있습니다.
[IDT entry]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
### IST와 TSS
인터럽트 스택 테이블 (IST)은 오래되어 이젠 구식이 된 _[Task State Segment]_ (TSS)라는 구조체의 일부입니다. 예전에 TSS는 다양한 정보 (예: 프로세서 레지스터들의 상태 값)를 저장하거나 [하드웨어를 이용한 컨텍스트 스위치][hardware context switching]을 지원하는 용도로 사용됐습니다. 하지만 하드웨어를 이용한 컨텍스트 스위치를 64비트 모드에서부터는 지원하지 않게 되었고, 그 이후 TSS의 구조는 완전히 바뀌었습니다.
[Task State Segment]: https://en.wikipedia.org/wiki/Task_state_segment
[hardware context switching]: https://wiki.osdev.org/Context_Switching#Hardware_Context_Switching
x86_64 아키텍처에서 TSS는 특정 태스크 (task) 관련 정보를 보관하지 않습니다. 대신 TSS는 두 개의 스택 테이블을 보관합니다 (IST가 그중 하나입니다). 32비트 시스템의 TSS와 64비트 시스템의 TSS의 유일한 공통 필드는 [I/O port permissions bitmap]에 대한 포인터 하나 뿐입니다.
[I/O port permissions bitmap]: https://en.wikipedia.org/wiki/Task_state_segment#I.2FO_port_permissions
64비트 TSS는 아래의 구조를 가집니다.
Field | Type
------ | ----------------
<span style="opacity: 0.5">(reserved)</span> | `u32`
Privilege Stack Table | `[u64; 3]`
<span style="opacity: 0.5">(reserved)</span> | `u64`
Interrupt Stack Table | `[u64; 7]`
<span style="opacity: 0.5">(reserved)</span> | `u64`
<span style="opacity: 0.5">(reserved)</span> | `u16`
I/O Map Base Address | `u16`
CPU가 특권 레벨을 교체할 때 _Privilege Stack Table_ 을 사용합니다. CPU가 사용자 모드일 때 (특권 레벨 = 3) 예외가 발생하면, CPU는 예외 처리 함수를 호출에 앞서 커널 모드로 전환합니다 (특권 레벨 = 0). 이 경우 CPU는 스택을 Privilege Stack Table의 0번째 스택으로 교체합니다 (특권 레벨이 0이라서). 아직 우리의 커널에서 동작하는 사용자 모드 프로그램이 없으므로, 일단은 이 테이블에 대해 걱정하지 않아도 됩니다.
### TSS 생성하기
새로운 TSS를 생성하고 TSS의 인터럽트 스택 테이블에 별도의 더블 폴트 스택을 갖추도록 코드를 작성하겠습니다. 우선 TSS를 나타낼 구조체가 필요하기에, `x86_64` 크레이트가 제공하는 [`TaskStateSegment` 구조체][`TaskStateSegment` struct]를 사용하겠습니다.
[`TaskStateSegment` struct]: https://docs.rs/x86_64/0.14.2/x86_64/structures/tss/struct.TaskStateSegment.html
새로운 모듈 `gdt`에 TSS를 생성합니다 (모듈 이름이 왜 gdt인지는 이후에 납득이 가실 겁니다).
```rust
// in src/lib.rs
pub mod gdt;
// in src/gdt.rs
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 5;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end
};
tss
};
}
```
Rust의 const evaluator가 위와 같은 TSS의 초기화를 컴파일 중에 진행하지 못해서 `lazy_static`을 사용합니다. IST의 0번째 엔트리가 더블 폴트 스택이 되도록 정합니다 (꼭 0번째일 필요는 없음). 그다음 더블 폴트 스택의 최상단 주소를 IST의 0번째 엔트리에 저장합니다. 스택의 최상단 주소를 저장하는 이유는 x86 시스템에서 스택은 높은 주소에서 출발해 낮은 주소 영역 쪽으로 성장하기 때문입니다.
우리가 아직 커널에 메모리 관리 (memory management) 기능을 구현하지 않아서 스택을 할당할 정규적인 방법이 없습니다. 임시방편으로 `static mut` 배열을 스택 메모리인 것처럼 사용할 것입니다. 값 변경이 가능한 static 변수에 접근하는 경우 컴파일러가 데이터 경쟁 상태 (data race)의 부재를 보장하지 못해 `unsafe` 키워드가 필요합니다. 배열은 꼭 `static`이 아닌 `static mut`로 설정해야 하는데, 그 이유는 부트로더가 `static` 변수를 읽기 전용 메모리 페이지에 배치하기 때문입니다. 이후에 다른 글에서 이 임시적인 스택 메모리 구현을 정석적인 구현으로 수정할 계획이며, 그 후에는 스택 메모리 접근에 더 이상 `unsafe`가 필요하지 않을 것입니다.
이 더블 폴트 스택에 스택 오버플로우를 감지하기 위한 보호 페이지가 없다는 것에 유의해야 합니다. 더블 폴트 스택에서 스택 오버플로우가 발생하면 스택 아래의 메모리 영역을 일부 덮어쓸 수 있기 때문에, 더블 폴트 처리 함수 안에서 스택 메모리를 과도하게 소모해서는 안됩니다.
#### TSS 불러오기
새로운 TSS도 만들었으니, 이제 CPU에게 이 TSS를 쓰도록 지시할 방법이 필요합니다. TSS가 역사적 이유로 인해 세그멘테이션 (segmentation) 시스템을 사용하는 탓에, CPU에 TSS를 쓰도록 지시하는 과정이 꽤 번거롭습니다. TSS를 직접 불러오는 대신, [전역 서술자 테이블 (Global Descriptor Table; GDT)][Global Descriptor Table]을 가리키는 새로운 세그먼트 서술자 (segment descriptor)를 추가해야 합니다. 그 후 [`ltr` 명령어][`ltr` instruction]에 GDT 안에서의 TSS의 인덱스를 주고 호출하여 TSS를 불러올 수 있습니다. (이것이 모듈 이름을 `gdt`로 설정한 이유입니다.)
[Global Descriptor Table]: https://web.archive.org/web/20190217233448/https://www.flingos.co.uk/docs/reference/Global-Descriptor-Table/
[`ltr` instruction]: https://www.felixcloutier.com/x86/ltr
### 전역 서술자 테이블 (Global Descriptor Table)
전역 서술자 테이블 (Global Descriptor Table; GDT)는 메모리 페이징이 표준이 되기 이전, [메모리 세그멘테이션 (memory segmentation)][memory segmentation]을 지원하는 데 쓰인 오래된 물건입니다. 64비트 모드에서도 여전히 여러 쓰임새가 있는데, 커널/사용자 모드 설정 및 TSS 불러오기 등의 용도에 쓰입니다.
[memory segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
GDT는 프로그램의 _세그먼트_ 들을 저장하는 구조체입니다. 메모리 페이징이 표준화되어 쓰이기 이전의 오래된 아키텍처들에서 프로그램들을 서로 격리할 때 GDT를 사용했습니다. 세그멘테이션에 대한 자세한 정보는 무료 공개된 [책 “Three Easy Pieces”][“Three Easy Pieces” book]의 Segmentation 챕터를 참고해 주세요. 세그멘테이션은 64비트 모드에서는 더 이상 지원되지 않지만, 그래도 GDT는 남아 있습니다. GDT는 대체로 2가지 용도에 쓰입니다: 1) 커널 공간과 사용자 공간 사이 교체를 진행할 때. 2) TSS 구조체를 불러올 때.
[“Three Easy Pieces” book]: http://pages.cs.wisc.edu/~remzi/OSTEP/
#### GDT 만들기
static 변수 `TSS`의 세그먼트를 포함하는 static `GDT`를 만듭니다.
```rust
// in src/gdt.rs
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
lazy_static! {
static ref GDT: GlobalDescriptorTable = {
let mut gdt = GlobalDescriptorTable::new();
gdt.add_entry(Descriptor::kernel_code_segment());
gdt.add_entry(Descriptor::tss_segment(&TSS));
gdt
};
}
```
이전처럼 `lazy_static`을 사용했습니다. 코드 세그먼트와 TSS 세그먼트를 포함하는 GDT를 만듭니다.
#### GDT 불러오기
GDT를 불러오는 용도의 함수 `gdt::init` 함수를 만들고, `init` 함수로부터 해당 함수를 호출합니다.
```rust
// in src/gdt.rs
pub fn init() {
GDT.load();
}
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
}
```
이제 GDT를 불러온 상태입니다만 (`_start` 함수가 `init` 함수를 호출했기 때문에), 여전히 커널 스택 오버플로우 발생 시 커널이 무한 재부팅 루프에 갇힙니다.
### 최종 단계
세그먼트 레지스터 및 TSS 레지스터가 기존의 GDT로부터 읽어온 값들을 저장하고 있는 탓에, 우리가 만든 GDT의 세그먼트들이 활성화되지 않은 상황입니다. 또한 더블 폴트 처리 함수가 새로운 스택을 쓰도록 IDT에서 더블 폴트 처리 함수의 엔트리를 알맞게 수정해야 합니다.
정리하자면 우리는 아래의 작업을 순차적으로 진행해야 합니다.
1. **code segment 레지스터의 값 갱신하기**: GDT를 변경하였으니 코드 세그먼트 레지스터 `cs`의 값도 갱신해야 합니다. 기존의 세그먼트 선택자는 새 GDT 안에서 코드 세그먼트가 아닌 다른 세그먼트의 선택자와 동일할 수도 있습니다 (예: TSS 선택자).
2. **TSS 불러오기**: GDT와 TSS 선택자를 불러오고, 그 후 CPU가 해당 TSS를 사용하도록 지시해야 합니다.
3. **IDT 엔트리 수정하기**: TSS를 불러온 시점부터 CPU는 유효한 인터럽트 스택 테이블 (IST)에 접근할 수 있습니다. 앞으로 더블 폴트 발생 시 CPU가 새로운 더블 폴트 스택으로 교체하도록, IDT에서 더블 폴트에 대응하는 엔트리를 알맞게 수정합니다.
첫 두 단계를 수행하려면 `gdt::init` 함수에서 두 변수 `code_selector``tss_selector`에 대한 접근할 수 있어야 합니다. `Selectors` 라는 새로운 구조체를 통해 해당 변수들을 `gdt::init` 함수에서 접근할 수 있게 만듭니다.
```rust
// in src/gdt.rs
use x86_64::structures::gdt::SegmentSelector;
lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}
struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}
```
이제 접근 가능해진 선택자들을 사용해 `cs` 레지스터의 값을 갱신하고 우리가 만든 `TSS`를 불러옵니다.
```rust
// in src/gdt.rs
pub fn init() {
use x86_64::instructions::tables::load_tss;
use x86_64::instructions::segmentation::{CS, Segment};
GDT.0.load();
unsafe {
CS::set_reg(GDT.1.code_selector);
load_tss(GDT.1.tss_selector);
}
}
```
[`set_cs`] 함수로 코드 세그먼트 레지스터의 값을 갱신하고, [`load_tss`] 함수로 우리가 만든 TSS를 불러옵니다. 이 함수들은 `unsafe` 함수로 정의되어 있어 `unsafe` 블록 안에서만 호출할 수 있습니다. 이 함수들이 `unsafe`로 정의된 이유는 해당 함수들에 유효하지 않은 선택자를 전달할 경우 메모리 안전성을 해칠 수 있기 때문입니다.
[`set_cs`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/segmentation/fn.set_cs.html
[`load_tss`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tables/fn.load_tss.html
유효한 TSS와 인터럽트 스택 테이블을 불러왔으니, 이제 더블 폴트 처리 함수가 사용할 스택의 인덱스를 IDT에서 지정해 봅시다.
```rust
// in src/interrupts.rs
use crate::gdt;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
unsafe {
idt.double_fault.set_handler_fn(double_fault_handler)
.set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // 새롭게 추가함
}
idt
};
}
```
`set_stack_index`가 unsafe 함수인 이유는, 이 함수를 호출하는 측에서 인덱스가 유효하고 다른 예외 처리 시 사용 중이지 않다는 것을 보장해야 하기 때문입니다.
수고하셨습니다! 이제부터 더블 폴트가 일어난다면 CPU는 스택을 더블 폴트 스택으로 교체할 것입니다. 드디어 커널 스택 오버플로우가 발생하는 상황을 포함하여 더블 폴트가 일어나는 _어떤 경우라도_ 더블 폴트를 처리할 수 있게 됐습니다.
![QEMU printing `EXCEPTION: DOUBLE FAULT` and a dump of the exception stack frame](qemu-double-fault-on-stack-overflow.png)
앞으로 트리플 폴트를 볼 일은 없을 겁니다! 위에서 구현한 내용을 우리가 미래에 실수로라도 훼손하지 않도록, 위 구현의 작동을 점검하는 테스트를 추가해 보겠습니다.
## 커널 스택 오버플로우 테스트
`gdt` 모듈을 테스트하고 커널 스택 오버플로우 발생 시 더블 폴트 처리 함수가 호출되는지 확인하는 용도의 통합 테스트를 추가할 것입니다. 테스트 함수에서 더블 폴트를 일으킨 후에 더블 폴트 처리 함수가 호출되었는지 확인하는 테스트를 작성하겠습니다.
최소한의 뼈대 코드에서부터 테스트 작성을 시작해 봅시다.
```rust
// in tests/stack_overflow.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
이전에 작성한 `panic_handler` 테스트처럼 이 테스트 또한 [테스트 하네스 (test harness) 없이][without a test harness] 실행될 것입니다. 그 이유는 더블 폴트가 발생한 후에는 프로그램의 정상 실행을 재개할 수가 없기 때문에 어차피 1개 이상의 테스트를 두는 것이 의미가 없기 때문입니다. 테스트 하네스를 사용하지 않도록 `Cargo.toml`에 아래의 코드를 추가합니다.
```toml
# in Cargo.toml
[[test]]
name = "stack_overflow"
harness = false
```
[without a test harness]: @/edition-2/posts/04-testing/index.ko.md#no-harness-tests
`cargo test --test stack_overflow` 실행 시 컴파일은 성공할 것이고, 테스트 내의 `unimplemented` 매크로 때문에 테스트 실행은 실패할 것입니다.
### `_start` 함수 구현
`_start` 함수의 코드 구현은 아래와 같습니다.
```rust
// in tests/stack_overflow.rs
use blog_os::serial_print;
#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_print!("stack_overflow::stack_overflow...\t");
blog_os::gdt::init();
init_test_idt();
// 스택 오버플로우 일으키기
stack_overflow();
panic!("Execution continued after stack overflow");
}
#[allow(unconditional_recursion)]
fn stack_overflow() {
stack_overflow(); // 재귀 호출할 때마다 반환 주소가 스택에 push 됩니다
volatile::Volatile::new(0).read(); // "tail call elimination" 방지하기
}
```
`gdt::init` 함수를 호출해 새 GDT를 초기화합니다. `interrupts::init_idt` 함수 대신 `init_test_idt` 함수를 호출하는데, 그 이유는 패닉하지 않고 `exit_qemu(QemuExitCode::Success)`를 호출하는 새로운 더블 폴트 처리 함수를 등록해 사용할 것이기 때문입니다.
`stack_overflow` 함수는 `main.rs`에서 작성했던 것과 거의 동일합니다. 유일한 차이점은 함수 마지막에 추가로 [`Volatile`] 타입을 이용한 [volatile] 읽기를 통해 [_tail call elimination_]을 방지한다는 것입니다. 주어진 함수의 맨 마지막 구문이 재귀 함수에 대한 호출인 경우, 컴파일러는 tail call elimination 기법을 통해 재귀 함수 호출을 평범한 반복문으로 변환할 수 있습니다. 그렇게 하면 재귀 함수 호출 시 새로운 스택 프레임이 생성되지 않고, 스택 메모리 사용량은 일정하게 유지됩니다.
[volatile]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
[`Volatile`]: https://docs.rs/volatile/0.2.6/volatile/struct.Volatile.html
[_tail call elimination_]: https://en.wikipedia.org/wiki/Tail_call
이 테스트에서 우리는 스택 오버플로우가 발생하기를 원하기 때문에, 함수의 맨 마지막에 컴파일러가 제거할 수 없는 volatile 읽기 작업을 삽입합니다. 따라서 `stack_overflow` 함수는 더 이상 _꼬리 재귀 (tail recursive)_ 함수가 아니게 되고, tail call elimination 기법을 통한 최적화 역시 할 수 없게 됩니다. 또 `allow(unconditional_recursion)` 속성을 함수에 추가해 "함수가 무한히 재귀한다"는 경고 메시지가 출력되지 않게 합니다.
### 테스트용 IDT
위에서 언급했듯이, 살짝 변경된 새로운 더블 폴트 처리 함수가 등록된 테스트용 IDT가 필요합니다. 테스트 용 IDT의 구현은 아래와 같습니다.
```rust
// in tests/stack_overflow.rs
use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;
lazy_static! {
static ref TEST_IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.double_fault
.set_handler_fn(test_double_fault_handler)
.set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
}
idt
};
}
pub fn init_test_idt() {
TEST_IDT.load();
}
```
코드 구현은 우리가 `interrupts.rs`에서 작성한 IDT와 매우 흡사합니다. 기존과 마찬가지로 더블 폴트 처리 함수가 사용할 스택의 인덱스를 정해줍니다. `init_test_idt` 함수는 `load` 함수를 통해 테스트 용 IDT를 CPU로 불러옵니다.
### 더블 폴트 처리 함수
마지막 남은 단계는 더블 폴트 처리 함수를 작성하는 것입니다. 코드 구현은 아래와 같습니다.
```rust
// in tests/stack_overflow.rs
use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;
extern "x86-interrupt" fn test_double_fault_handler(
_stack_frame: InterruptStackFrame,
_error_code: u64,
) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
```
더블 폴트 처리 함수가 호출되면 우리는 성공 종료 코드와 함께 QEMU를 종료시키고, 테스트는 성공한 것으로 처리됩니다. 통합 테스트는 완전히 독립적인 실행 파일로 간주하기 때문에, 다시 한번 테스트 파일의 맨 위에 `#![feature(abi_x86_interrupt)]` 속성을 추가해야 합니다.
`cargo test --test stack_overflow`를 통해 새로 작성한 테스트를 실행할 수 있습니다 (또는 `cargo test`로 모든 테스트 실행). 예상대로 콘솔에
`stack_overflow... [ok]` 라는 메시지가 출력될 것입니다. 테스트 코드에서 `set_stack_index`를 호출하지 않게 주석 처리한 후 테스트를 실행하면 테스트가 실패하는 것 또한 확인할 수 있을 것입니다.
## 정리
이 글에서는 더블 폴트와 더블 폴트의 발생 조건에 대해 배웠습니다. 오류 메시지를 출력하는 간단한 더블 폴트 처리 함수를 커널에 추가했고, 해당 함수의 올바른 동작을 점검하는 통합 테스트도 추가했습니다.
또한 우리는 더블 폴트 발생 시 하드웨어의 스택 교체 기능을 통해 커널 스택 오버 플로우 발생 시에도 더블 폴트가 제대로 처리되도록 구현했습니다. 구현 과정에서 Task State Segment (TSS)와 그 안에 포함된 인터럽트 스택 테이블 (Interrupt Stack Table; IST), 그리고 오래된 아키텍처들에서 세그멘테이션 (segmentation)에 사용됐던 전역 서술자 테이블 (Global Descriptor Table; GDT)에 대해 배웠습니다.
## 다음 단계는 무엇일까요?
다음 글에서는 타이머, 키보드, 네트워크 컨트롤러 등의 외부 장치로부터 전송되어 오는 인터럽트들을 처리하는 방법에 대해 설명하겠습니다. 이러한 하드웨어 인터럽트들은 예외와 마찬가지로 IDT에 등록된 처리 함수를 통해 처리된다는 점에서 유사합니다. 인터럽트가 예외와 다른 점은 예외와 달리 CPU로부터 발생하지 않는다는 것입니다. 대신에 _인터럽트 컨트롤러 (interrupt controller)_ 가 외부 장치로부터 전송되어 오는 인터럽트들을 수합한 후 인터럽트 우선 순위에 맞춰 CPU로 인터럽트들을 전달합니다. 다음 글에서 [Intel 8259] (“PIC”) 인터럽트 컨트롤러에 대해 알아보고, 키보드 입력을 지원하는 법을 배울 것입니다.
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259

View File

@@ -1,562 +0,0 @@
+++
title = "Double Faults"
weight = 6
path = "zh-CN/double-fault-exceptions"
date = 2018-06-18
[extra]
# Please update this when updating the translation
translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"
# GitHub usernames of the people that translated this post
translators = ["liuyuran"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["JiangengDong"]
+++
在这篇文章中,我们会探索 double fault 异常的细节,它的触发条件是调用错误处理函数失败。通过捕获该异常,我们可以阻止致命的 _triple faults_ 异常导致系统重启。为了尽可能避免 triple faults ,我们会在一个独立的内核栈配置 _中断栈表_ 来捕捉 double faults。
<!-- more -->
这个系列的blog在[GitHub]上开放开发如果你有任何问题请在这里开一个issue来讨论。当然你也可以在[底部][at the bottom]留言。你可以在[`post-06`][post branch]找到这篇文章的完整源码。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-06
<!-- toc -->
## 何谓 Double Fault
简而言之double fault 就是当CPU执行错误处理函数失败时抛出的特殊异常。比如你没有注册在 [中断描述符表][IDT] 中注册对应 page fault 异常的处理函数,然后程序偏偏就抛出了一个 page fault 异常,这时候就会接着抛出 double fault 异常。这个异常的处理函数就比较类似于具备异常处理功能的编程语言里的 catch-all 语法的效果,比如 C++ 里的 `catch(...)` 和 JAVA/C# 里的 `catch(Exception e)`
[IDT]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
double fault 的行为和普通异常十分相似我们可以通过在IDT中注册 `8` 号位的处理函数来拦截该异常。这个处理函数十分重要如果你不处理这个异常CPU就会直接抛出 _triple fault_ 异常,该异常无法被任何方式处理,而且会直接导致绝大多数硬件强制重启。
### 捕捉 Double Fault
我们先来试试看不捕捉 double fault 的情况下触发它会有什么后果:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// trigger a page fault
unsafe {
*(0xdeadbeef as *mut u8) = 42;
};
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}
```
这里我们使用 `unsafe` 块直接操作了一个无效的内存地址 `0xdeadbeef`,由于该虚拟地址并未在页表中映射到物理内存,所以必然会抛出 page fault 异常。我们又并未在 [IDT] 中注册对应的处理器,所以 double fault 会紧接着被抛出。
现在启动内核,我们可以看到它直接陷入了崩溃和重启的无限循环,其原因如下:
1. CPU试图向 `0xdeadbeef` 写入数据,这就造成了 page fault 异常。
2. CPU没有在IDT中找到相应的处理函数所以又抛出了 double fault 异常。
3. CPU再一次没有在IDT中找到相应的处理函数所以又抛出了 _triple fault_ 异常。
4. 在抛出 triple fault 之后就没有然后了这个错误是致命级别如同大多数硬件一样QEMU对此的处理方式就是重置系统也就是重启。
通过这个小实验,我们知道在这种情况下,需要提前注册 page faults 或者 double fault 的处理函数才行,但如果想要在任何场景下避免触发 triple faults 异常,则必须注册能够捕捉一切未注册异常类型的 double fault 处理函数。
## 处理 Double Fault
double fault 是一个带错误码的常规错误,所以我们可以参照 breakpoint 处理函数定义一个 double fault 处理函数:
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler); // new
idt
};
}
// new
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame, _error_code: u64) -> !
{
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}
```
我们的处理函数打印了一行简短的信息并将栈帧转写了出来。其中错误码一直是0所以没有必要把它打印出来。要说这和 breakpoint 处理函数有什么区别,那就是 double fault 的处理函数是 [发散的][_diverging_],这是因为 `x86_64` 架构不允许从 double fault 异常中返回任何东西。
[_diverging_]: https://doc.rust-lang.org/stable/rust-by-example/fn/diverging.html
那么再次启动内核,我们可以看到 double fault 的处理函数被成功调用:
![QEMU printing `EXCEPTION: DOUBLE FAULT` and the exception stack frame](qemu-catch-double-fault.png)
让我们来分析一下又发生了什么:
1. CPU尝试往 `0xdeadbeef` 写入数据,引发了 page fault 异常。
2. 如同上次运行一样CPU并没有在IDT里找到对应的处理函数所以又引发了 double fault 异常。
3. CPU又跳转到了我们刚刚定义的 double fault 处理函数。
现在 triple fault 及其衍生的重启循环不会再出现了因为CPU已经妥善处理了 double fault 异常。
这还真是直截了当对吧,但为什么要为这点内容单独写一篇文章呢?没错,我们的确已经可以捕获 _大部分_ double faults 异常,但在部分情况下,这样的做法依然不够。
## Double Faults 的成因
在解释这些部分情况之前,我们需要先明确一下 double faults 的成因,上文中我们使用了一个模糊的定义:
> double fault 就是当CPU执行错误处理函数失败时抛出的特殊异常。
但究竟什么叫 _“调用失败”_ ?没有提供处理函数?处理函数被[换出][swapped out]内存了?或者处理函数本身也出现了异常?
[swapped out]: http://pages.cs.wisc.edu/~remzi/OSTEP/vm-beyondphys.pdf
比如以下情况出现时:
1. 如果 breakpoint 异常被触发,但其对应的处理函数已经被换出内存了?
2. 如果 page fault 异常被触发,但其对应的处理函数已经被换出内存了?
3. 如果 divide-by-zero 异常处理函数又触发了 breakpoint 异常,但 breakpoint 异常处理函数已经被换出内存了?
4. 如果我们的内核发生了栈溢出,意外访问到了 _guard page_
幸运的是AMD64手册[PDF][AMD64 manual]给出了一个准确的定义在8.2.9这个章节中)。
根据里面的说法“double fault” 异常 __ 在执行主要(一层)异常处理函数时触发二层异常时触发。
这个“会”字十分重要:只有特定的两个异常组合会触发 double fault。
这些异常组合如下:
| 一层异常 | 二层异常 |
| --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| [Divide-by-zero],<br>[Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault] | [Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault] |
| [Page Fault] | [Page Fault],<br>[Invalid TSS],<br>[Segment Not Present],<br>[Stack-Segment Fault],<br>[General Protection Fault] |
[Divide-by-zero]: https://wiki.osdev.org/Exceptions#Division_Error
[Invalid TSS]: https://wiki.osdev.org/Exceptions#Invalid_TSS
[Segment Not Present]: https://wiki.osdev.org/Exceptions#Segment_Not_Present
[Stack-Segment Fault]: https://wiki.osdev.org/Exceptions#Stack-Segment_Fault
[General Protection Fault]: https://wiki.osdev.org/Exceptions#General_Protection_Fault
[Page Fault]: https://wiki.osdev.org/Exceptions#Page_Fault
[AMD64 manual]: https://www.amd.com/system/files/TechDocs/24593.pdf
所以那些假设里的 divide-by-zero 异常处理函数触发了 page fault 并不会出问题,只会紧接着触发下一个异常处理函数。但如果 divide-by-zero 异常处理函数触发的是 general-protection fault则一定会触发 double fault。
那么根据上表,我们可以回答刚刚的假设中的前三个:
1. 如果 breakpoint 异常被触发但对应的处理函数被换出了内存_page fault_ 异常就会被触发,并调用其对应的异常处理函数。
2. 如果 page fault 异常被触发,但对应的处理函数被换出了内存,那么 _double fault_ 异常就会被触发,并调用其对应的处理函数。
3. 如果 divide-by-zero 异常处理函数又触发了 breakpoint 异常,但 breakpoint 异常处理函数已经被换出内存了,那么被触发的就是 _page fault_ 异常。
实际上因在IDT里找不到对应处理函数而抛出异常的内部机制是当异常发生时CPU会去试图读取对应的IDT条目如果该条目不是一个有效的条目即其值为0就会触发 _general protection fault_ 异常。但我们同样没有为该异常注册处理函数,所以又一个 general protection fault 被触发了,随后 double fault 也被触发了。
### 内核栈溢出
现在让我们看一下第四个假设:
> 如果我们的内核发生了栈溢出,意外访问到了 _guard page_
guard page 是一类位于栈底部的特殊内存页,所以如果发生了栈溢出,最典型的现象就是访问这里。这类内存页不会映射到物理内存中,所以访问这里只会造成 page fault 异常而不会污染其他内存。bootloader 已经为我们的内核栈设置好了一个 guard page所以栈溢出会导致 _page fault_ 异常。
当 page fault 发生时CPU会在IDT寻找对应的处理函数并尝试将 [中断栈帧][interrupt stack frame] 入栈,但此时栈指针指向了一个实际上并不存在的 guard page然后第二个 page fault 异常就被触发了根据上面的表格double fault 也随之被触发了。
[interrupt stack frame]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-stack-frame
这时CPU会尝试调用 _double fault_ 对应的处理函数然而CPU依然会试图将错误栈帧入栈由于栈指针依然指向 guard page于是 _第三次_ page fault 发生了,最终导致 _triple fault_ 异常的抛出,系统因此重启。所以仅仅是注册错误处理函数并不能在此种情况下阻止 triple fault 的发生。
让我们来尝试一下,写一个能造成栈溢出的递归函数非常简单:
```rust
// in src/main.rs
#[no_mangle] // 禁止函数名自动修改
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
fn stack_overflow() {
stack_overflow(); // 每一次递归都会将返回地址入栈
}
// 触发 stack overflow
stack_overflow();
[] // test_main(), println(…), and loop {}
}
```
我们在QEMU执行这段程序然后系统就再次进入了重启循环。
所以我们要如何避免这种情况我们无法忽略异常栈帧入栈这一步因为这个逻辑是内置在CPU里的。所以我们需要找个办法让栈在 double fault 异常发生后始终有效。幸运的是x86_64 架构对于这个问题已经给出了解决方案。
## 切换栈
x86_64 架构允许在异常发生时将栈切换为一个预定义的完好栈这个切换是执行在硬件层次的所以完全可以在CPU将异常栈帧入栈之前执行。
这个切换机制是由 _中断栈表_ IST实现的IST是一个由7个确认可用的完好栈的指针组成的用 Rust 语言可以表述为:
```rust
struct InterruptStackTable {
stack_pointers: [Option<StackPointer>; 7],
}
```
对于每一个错误处理函数,我们都可以通过对应的[IDT条目][IDT entry]中的 `stack_pointers` 条目指定IST中的一个栈。比如我们可以让 double fault 对应的处理函数使用IST中的第一个栈指针则CPU会在这个异常发生时自动将栈切换为该栈。该切换行为会在所有入栈操作之前进行由此可以避免进一步触发 triple fault 异常。
[IDT entry]: @/edition-2/posts/05-cpu-exceptions/index.md#the-interrupt-descriptor-table
### IST和TSS
中断栈表IST其实是一个名叫 _[任务状态段][Task State Segment]TSS_ 的古老遗留结构的一部分。
TSS是用来存储32位任务中的零碎信息比如处理器寄存器的状态一般用于 [硬件上下文切换][hardware context switching]。但是硬件上下文切换已经不再适用于64位模式并且TSS的实际数据结构也已经发生了彻底的改变。
[Task State Segment]: https://en.wikipedia.org/wiki/Task_state_segment
[hardware context switching]: https://wiki.osdev.org/Context_Switching#Hardware_Context_Switching
在 x86_64 架构中TSS已经不再存储任何任务相关信息取而代之的是两个栈表IST正是其中之一
32位TSS和64位TSS唯一的共有字段恐怕就是指向 [I/O端口权限位图][I/O port permissions bitmap] 的指针了。
[I/O port permissions bitmap]: https://en.wikipedia.org/wiki/Task_state_segment#I.2FO_port_permissions
64位TSS的格式如下
| 字段 | 类型 |
| ---------------------------------------- | ---------- |
| <span style="opacity: 0.5">(保留)</span> | `u32` |
| 特权栈表 | `[u64; 3]` |
| <span style="opacity: 0.5">(保留)</span> | `u64` |
| 中断栈表 | `[u64; 7]` |
| <span style="opacity: 0.5">(保留)</span> | `u64` |
| <span style="opacity: 0.5">(保留)</span> | `u16` |
| I/O映射基准地址 | `u16` |
_特权栈表_ 在 CPU 特权等级变更的时候会被用到。例如当 CPU 在用户态特权等级3中触发一个异常时一般情况下 CPU 会在执行错误处理函数前切换到内核态特权等级0在这种情况下CPU 会切换为特权栈表的第0层0层是目标特权等级。但是目前我们还没有用户态的程序所以暂且可以忽略这个表。
### 创建一个TSS
那么我们来创建一个新的包含单独的 double fault 专属栈以及中断栈表的TSS。为此我们需要一个TSS结构体幸运的是 `x86_64` crate 也已经自带了 [`TaskStateSegment` 结构][`TaskStateSegment` struct] 用来映射它。
[`TaskStateSegment` struct]: https://docs.rs/x86_64/0.14.2/x86_64/structures/tss/struct.TaskStateSegment.html
那么我们新建一个 `gdt` 模块稍后会说明为何要使用这个名字用来创建TSS
```rust
// in src/lib.rs
pub mod gdt;
// in src/gdt.rs
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 5;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end
};
tss
};
}
```
这次依然是使用 `lazy_static`Rust的静态变量求值器还没有强大到能够在编译器执行初始化代码。我们将IST的0号位定义为 double fault 的专属栈其他IST序号也可以如此施为。然后我们将栈的高地址指针写入0号位之所以这样做那是因为 x86 的栈内存分配是从高地址到低地址的。
由于我们还没有实现内存管理机制,所以目前无法直接申请新栈,但我们可以使用 `static mut` 形式的数组来在内存中模拟出栈存储区。`unsafe` 块也是必须的,因为编译器认为这种可以被竞争的变量是不安全的,而且这里必须是 `static mut` 而不是不可修改的 `static`,否则 bootloader 会将其分配到只读页中。当然,在后续的文章中,我们会将其修改为真正的栈分配,`unsafe` 块也一定会去掉的。
但要注意,由于现在 double fault 获取的栈不再具有用于防止栈溢出的 guard page所以我们不应该做任何栈密集型操作了否则就有可能会污染到栈下方的内存区域。
#### 加载TSS
我们已经创建了一个TSS现在的问题就是怎么让CPU使用它。不幸的是这事有点繁琐因为TSS用到了分段系统历史原因。但我们可以不直接加载而是在[全局描述符表][Global Descriptor Table]GDT中添加一个段描述符然后我们就可以通过[`ltr` 指令][`ltr` instruction]加上GDT序号加载我们的TSS。这也是为什么我们将模块取名为 `gdt`。)
[Global Descriptor Table]: https://web.archive.org/web/20190217233448/https://www.flingos.co.uk/docs/reference/Global-Descriptor-Table/
[`ltr` instruction]: https://www.felixcloutier.com/x86/ltr
### 全局描述符表
全局描述符表GDT是分页模式成为事实标准之前用于[内存分段][memory segmentation]的遗留结构但它在64位模式下仍然需要处理一些事情比如内核态/用户态的配置以及TSS载入。
[memory segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
GDT是包含了程序 _段信息_ 的结构,在分页模式成为标准前,它在旧架构下起到隔离程序执行环境的作用。要了解更多关于分段的知识,可以查看 [“Three Easy Pieces” book] 这本书的同名章节。尽管GDT在64位模式下已经不再受到支持但其依然有两个作用切换内核空间和用户空间以及加载TSS结构。
[“Three Easy Pieces” book]: http://pages.cs.wisc.edu/~remzi/OSTEP/
#### 创建GDT
我们来创建一个包含了静态 `TSS` 段的 `GDT` 静态结构:
```rust
// in src/gdt.rs
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
lazy_static! {
static ref GDT: GlobalDescriptorTable = {
let mut gdt = GlobalDescriptorTable::new();
gdt.add_entry(Descriptor::kernel_code_segment());
gdt.add_entry(Descriptor::tss_segment(&TSS));
gdt
};
}
```
就像以前一样,我们依然使用了 `lazy_static`我们通过这段代码创建了TSS和GDT两个结构。
#### 加载GDT
我们先创建一个在 `init` 函数中调用的 `gdt::init` 函数:
```rust
// in src/gdt.rs
pub fn init() {
GDT.load();
}
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
}
```
现在GDT成功加载了进去`_start` 会调用 `init` 函数),但我们依然会看到由于栈溢出引发的重启循环。
### 最终步骤
现在的问题就变成了GDT并未被激活代码段寄存器和TSS实际上依然引用着旧的GDT并且我们也需要修改 double fault 对应的IDT条目使其使用新的栈。
总结一下,我们需要做这些事情:
1. **重载代码段寄存器**: 我们修改了GDT所以就需要重载代码段寄存器 `cs`这一步对于修改GDT信息而言是必须的比如覆写TSS。
2. **加载TSS** : 我们已经加载了包含TSS信息的GDT但我们还需要告诉CPU使用新的TSS。
3. **更新IDT条目**: 当TSS加载完毕后CPU就可以访问到新的中断栈表IST下面我们需要通过修改IDT条目告诉CPU使用新的 double fault 专属栈。
通过前两步,我们可以在 `gdt::init` 函数中调用 `code_selector``tss_selector` 两个变量,我们可以将两者打包为一个 `Selectors` 结构便于使用:
```rust
// in src/gdt.rs
use x86_64::structures::gdt::SegmentSelector;
lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}
struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}
```
现在我们可以使用这两个变量去重载代码段寄存器 `cs` 并重载 `TSS`
```rust
// in src/gdt.rs
pub fn init() {
use x86_64::instructions::tables::load_tss;
use x86_64::instructions::segmentation::{CS, Segment};
GDT.0.load();
unsafe {
CS::set_reg(GDT.1.code_selector);
load_tss(GDT.1.tss_selector);
}
}
```
我们通过 [`set_cs`] 覆写了代码段寄存器,然后使用 [`load_tss`] 来重载了TSS不过这两个函数都被标记为 `unsafe`,所以 `unsafe` 代码块是必须的。
原因很简单,如果通过这两个函数加载了无效的指针,那么很可能就会破坏掉内存安全性。
[`set_cs`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/segmentation/fn.set_cs.html
[`load_tss`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tables/fn.load_tss.html
现在我们已经加载了有效的TSS和中断栈表我们可以在IDT中为 double fault 对应的处理函数设置栈序号:
```rust
// in src/interrupts.rs
use crate::gdt;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
unsafe {
idt.double_fault.set_handler_fn(double_fault_handler)
.set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new
}
idt
};
}
```
`set_stack_index` 函数也是不安全的,因为栈序号的有效性和引用唯一性是需要调用者去确保的。
搞定现在CPU会在 double fault 异常被触发时自动切换到安全栈了,我们可以捕捉到 _所有_ 的 double fault包括内核栈溢出
![QEMU printing `EXCEPTION: DOUBLE FAULT` and a dump of the exception stack frame](qemu-double-fault-on-stack-overflow.png)
现在开始我们应该不会再看到 triple fault 了,但要确保这部分逻辑不被破坏,我们还需要为其添加一个测试。
## 栈溢出测试
要测试我们的 `gdt` 模块,并确保在栈溢出时可以正确捕捉 double fault我们可以添加一个集成测试。基本上就是在测试函数中主动触发一个 double fault 异常,确认异常处理函数是否正确运行了。
让我们建立一个最小化框架:
```rust
// in tests/stack_overflow.rs
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
就如同 `panic_handler` 这个测试一样,该测试应该是一个 [无约束测试][without a test harness],其原因就是我们无法在 double fault 被抛出后继续运行,所以连续进行多个测试其实是说不通的。要将测试修改为无约束模式,我们需要将这一段配置加入 `Cargo.toml`
```toml
# in Cargo.toml
[[test]]
name = "stack_overflow"
harness = false
```
[without a test harness]: @/edition-2/posts/04-testing/index.md#no-harness-tests
现在 `cargo test --test stack_overflow` 命令应当可以通过编译了。但是毫无疑问的是还是会执行失败,因为 `unimplemented` 宏必然会导致程序报错。
### 实现 `_start`
`_start` 函数实现后的样子是这样的:
```rust
// in tests/stack_overflow.rs
use blog_os::serial_print;
#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_print!("stack_overflow::stack_overflow...\t");
blog_os::gdt::init();
init_test_idt();
// trigger a stack overflow
stack_overflow();
panic!("Execution continued after stack overflow");
}
#[allow(unconditional_recursion)]
fn stack_overflow() {
stack_overflow(); // for each recursion, the return address is pushed
volatile::Volatile::new(0).read(); // prevent tail recursion optimizations
}
```
我们调用了 `gdt::init` 函数来初始化GDT但我们并没有调用 `interrupts::init_idt` 函数,而是调用了一个全新的 `init_test_idt` 函数,我们稍后来实现它。原因就是,我们需要注册一个自定义的 double fault 处理函数,在被触发的时候调用 `exit_qemu(QemuExitCode::Success)` 函数,而非使用默认的逻辑。
`stack_overflow` 函数和我们之前在 `main.rs` 中写的那个函数几乎一模一样,唯一的区别就是在函数的最后使用 [`Volatile`] 类型 加入了一个 [volatile] 读取操作,用来阻止编译器进行 [_尾调用优化_][_tail call elimination_]。除却其他乱七八糟的效果,这个优化最主要的影响就是会让编辑器将最后一行是递归语句的函数转化为普通的循环。由于没有通过递归创建新的栈帧,所以栈自然也不会出问题。
[volatile]: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
[`Volatile`]: https://docs.rs/volatile/0.2.6/volatile/struct.Volatile.html
[_tail call elimination_]: https://en.wikipedia.org/wiki/Tail_call
在当前用例中stack overflow 是必须要触发的,所以我们在函数尾部加入了一个无效的 volatile 读取操作来让编译器无法进行此类优化,递归也就无法被自动降级为循环了。当然,为了关闭编译器针对递归的安全警告,我们也需要为这个函数加上 `allow(unconditional_recursion)` 开关。
### 测试 IDT
作为上一小节的补充我们说过要在测试专用的IDT中实现一个自定义的 double fault 异常处理函数,就像这样:
```rust
// in tests/stack_overflow.rs
use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;
lazy_static! {
static ref TEST_IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.double_fault
.set_handler_fn(test_double_fault_handler)
.set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
}
idt
};
}
pub fn init_test_idt() {
TEST_IDT.load();
}
```
这和我们在 `interrupts.rs` 中实现的版本十分相似如同正常的IDT一样我们都为 double fault 使用IST序号设置了特殊的栈而上文中提到的 `init_test_idt` 函数则通过 `load` 函数将配置成功装载到CPU。
### Double Fault 处理函数
那么现在就差处理函数本身了,它看起来是这样子的:
```rust
// in tests/stack_overflow.rs
use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;
extern "x86-interrupt" fn test_double_fault_handler(
_stack_frame: InterruptStackFrame,
_error_code: u64,
) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}
```
这个处理函数被调用后我们会使用代表成功的返回值退出QEMU以此即可标记测试完成但由于集成测试处于完全独立的运行环境也记得在测试入口文件的头部再次加入 `#![feature(abi_x86_interrupt)]` 开关。
现在我们可以执行 `cargo test --test stack_overflow` 运行当前测试(或者执行 `cargo test` 运行所有测试),应当可以在控制台看到 `stack_overflow... [ok]` 这样的输出。另外,也可以试一下注释掉 `set_stack_index` 这一行的命令,可以观察到失败情况下的输出。
## 总结
在本文中,我们学到了 double fault 是什么,以及触发它的原因。我们为 double fault 写了相应的处理函数、将错误信息打印到控制台并为它添加了一个集成测试。
同时,我们为 double fault 启用了栈指针切换功能使其在栈溢出时也可以正常工作。在实现这个功能的同时我们也学习了在旧架构中用于内存分段的任务状态段TSS而该结构又包含了中断栈表IST和全局描述符表GDT
## 下期预告
在下一篇文章中我们会展开来说外部设备如定时器、键盘、网络控制器中断的处理方式。这些硬件中断十分类似于上文所说的异常都需要通过IDT进行处理只是中断并不是由CPU抛出的。 _中断控制器_ 会代理这些中断事件并根据中断的优先级将其转发给CPU处理。我们将会以 [Intel 8259] (PIC) 中断控制器为例对其进行探索,并实现对键盘的支持。
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259

View File

@@ -1,739 +0,0 @@
+++
title = "وقفه‌های سخت‌افزاری"
weight = 7
path = "fa/hardware-interrupts"
date = 2018-10-22
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "b6ff79ac3290ea92c86763d49cc6c0ff4fb0ea30"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
در این پست ما کنترل کننده قابل برنامه ریزی وقفه را تنظیم می کنیم تا وقفه های سخت افزاری را به درستی به پردازنده منتقل کند. برای مدیریت این وقفه‌ها ، موارد جدیدی به جدول توصیف کننده وقفه اضافه می کنیم ، دقیقاً مانند کارهایی که برای کنترل کننده های استثنا انجام دادیم. ما یاد خواهیم گرفت که چگونه وقفه های متناوب تایمر را گرفته و چگونه از صفحه کلید ورودی بگیریم.
<!-- more -->
این بلاگ بصورت آزاد بر روی [گیت‌هاب] توسعه داده شده. اگر مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. همچنین می‌توانید [در زیر] این پست کامنت بگذارید. سورس کد کامل این پست را می‌توانید در بِرَنچ [`post-07`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-07
<!-- toc -->
## مقدمه
وقفه‌ها راهی برای اطلاع به پردازنده از دستگاه های سخت افزاری متصل ارائه می دهند. بنابراین به جای اینکه پردازنده به طور دوره‌ای صفحه کلید را برای کاراکترهای جدید بررسی کند(فرآیندی به نام [_polling_]) ، صفحه کلید می‌تواند هسته را برای هر فشردن کلید مطلع کند. این بسیار کارآمدتر است زیرا هسته فقط زمانی که اتفاقی افتاده است باید عمل کند. همچنین زمان واکنش سریع تری را فراهم می کند ، زیرا هسته می تواند بلافاصله و نه تنها در پول(کلمه: poll) بعدی واکنش نشان دهد.
[_polling_]: https://en.wikipedia.org/wiki/Polling_(computer_science)
اتصال مستقیم تمام دستگاه های سخت افزاری به پردازنده امکان پذیر نیست. در عوض ، یک _کنترل کننده وقفه_ جداگانه ، وقفه‌ها را از همه دستگاه‌ها جمع کرده و سپس پردازنده را مطلع می کند:
```
____________ _____
Timer ------------> | | | |
Keyboard ---------> | Interrupt |---------> | CPU |
Other Hardware ---> | Controller | |_____|
Etc. -------------> |____________|
```
بیشتر کنترل کننده های وقفه قابل برنامه ریزی هستند ، به این معنی که آنها از اولویت های مختلف برای وقفه‌ها پشتیبانی می کنند. به عنوان مثال ، این اجازه را می دهند تا به وقفه های تایمر اولویت بیشتری نسبت به وقفه های صفحه کلید داد تا از زمان بندی دقیق اطمینان حاصل شود.
بر خلاف استثناها ، وقفه های سخت افزاری _به صورت نا هم زمان_ اتفاق می افتند. این بدان معنی است که آنها کاملاً از کد اجرا شده مستقل هستند و در هر زمان ممکن است رخ دهند. بنابراین ما ناگهان شکلی از همروندی در هسته خود با تمام اشکالات احتمالی مرتبط با همروندی داریم. مدل مالکیت دقیق راست در اینجا به ما کمک می کند زیرا مانع حالت تغییر پذیری گلوبال است(mutable global state). با این حال، همچنان احتمال بن بست وجود دارد، همانطور که بعداً در این پست خواهیم دید.
## The 8259 PIC
[Intel 8259] یک کنترل کننده وقفه قابل برنامه ریزی (PIC) است که در سال 1976 معرفی شد. مدت طولانی است که با [APIC] جدید جایگزین شده است ، اما رابط آن هنوز به دلایل سازگاری در سیستم های فعلی پشتیبانی می شود. 8259 PIC به طور قابل ملاحظه ای آسان تر از APIC است ، بنابراین ما قبل از مهاجرت و استفاده از APIC در آینده، از آن برای معرفی وقفه استفاده خواهیم کرد.
[APIC]: https://en.wikipedia.org/wiki/Intel_APIC_Architecture
8259 دارای 8 خط وقفه و چندین خط برای برقراری ارتباط با پردازنده است. سیستم های معمولی در آن زمان به دو نمونه از 8259 PIC مجهز بودند ، یکی اصلی و دیگری PIC ثانویه که به یکی از خطوط وقفه اولیه متصل است:
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259
```
____________ ____________
Real Time Clock --> | | Timer -------------> | |
ACPI -------------> | | Keyboard-----------> | | _____
Available --------> | Secondary |----------------------> | Primary | | |
Available --------> | Interrupt | Serial Port 2 -----> | Interrupt |---> | CPU |
Mouse ------------> | Controller | Serial Port 1 -----> | Controller | |_____|
Co-Processor -----> | | Parallel Port 2/3 -> | |
Primary ATA ------> | | Floppy disk -------> | |
Secondary ATA ----> |____________| Parallel Port 1----> |____________|
```
این نمودار نحوه اتصال معمول خطوط وقفه را نشان می دهد. می بینیم که بیشتر 15 خط دارای یک نگاشت ثابت هستند ، به عنوان مثال خط 4 PIC ثانویه به ماوس اختصاص داده شده است.
هر کنترل کننده را می توان از طریق دو [پورت ورودی/خروجی] ، یک پورت "فرمان" و یک پورت "داده" پیکربندی کرد. برای کنترل کننده اصلی ، این پورت‌ها `0x20` (فرمان) و`0x21` (داده) هستند. برای کنترل کننده ثانویه آنها `0xa0` (فرمان) و `0xa1` (داده) هستند. برای اطلاعات بیشتر در مورد نحوه پیکربندی PIC ها ، به [مقاله‌ای در osdev.org] مراجعه کنید.
[پورت ورودی/خروجی]: @/edition-2/posts/04-testing/index.md#i-o-ports
[مقاله‌ای در osdev.org]: https://wiki.osdev.org/8259_PIC
### پیاده سازی
پیکربندی پیش فرض PIC ها قابل استفاده نیست، زیرا اعداد بردار وقفه را در محدوده 15-0 به پردازنده می فرستد. این اعداد در حال حاضر توسط استثناهای پردازنده اشغال شده‌اند ، به عنوان مثال شماره 8 مربوط به یک خطای دوگانه است. برای رفع این مشکل همپوشانی، باید وقفه های PIC را به اعداد دیگری تغییر دهیم. دامنه واقعی مهم نیست به شرطی که با استثناها همپوشانی نداشته باشد ، اما معمولاً محدوده 47-32 انتخاب می شود، زیرا اینها اولین شماره های آزاد پس از 32 اسلات استثنا هستند.
پیکربندی با نوشتن مقادیر ویژه در پورت های فرمان و داده PIC ها اتفاق می افتد. خوشبختانه قبلا کرت‌ای به نام [`pic8259`] وجود دارد، بنابراین نیازی نیست که توالی راه اندازی اولیه را خودمان بنویسیم. در صورت علاقه‌مند بودن به چگونگی عملکرد آن، [کد منبع آن][pic crate source] را بررسی کنید، نسبتاً کوچک و دارای مستند خوبی است.
[pic crate source]: https://docs.rs/crate/pic8259/0.10.1/source/src/lib.rs
برای افزودن کرت به عنوان وابستگی ، موارد زیر را به پروژه خود اضافه می کنیم:
[`pic8259`]: https://docs.rs/pic8259/0.10.1/pic8259/
```toml
# in Cargo.toml
[dependencies]
pic8259 = "0.10.1"
```
انتزاع اصلی ارائه شده توسط کرت، ساختمان [`ChainedPics`] است که نمایانگر طرح اولیه/ثانویه PIC است که در بالا دیدیم. برای استفاده به روش زیر طراحی شده است:
[`ChainedPics`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html
```rust
// in src/interrupts.rs
use pic8259::ChainedPics;
use spin;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
```
همانطور که در بالا اشاره کردیم، افست PIC ها را در محدوده 47-32 تنظیم می کنیم. با بسته بندی ساختمان `ChainedPics` در `Mutex` می توانیم دسترسی قابل تغییر و ایمن (از طریق [متد lock][spin mutex lock]) به آن داشته باشیم، که در مرحله بعدی به آن نیاز داریم. تابع `ChainedPics::new` ناامن است زیرا افست اشتباه ممکن است باعث رفتار نامشخص شود.
[spin mutex lock]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html#method.lock
اکنون می توانیم 8259 PIC را در تابع `init` خود مقدار دهی اولیه کنیم:
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() }; // new
}
```
ما از تابع [`initialize`] برای انجام مقداردهی اولیه PIC استفاده می کنیم. مانند تابع `ChainedPics::new`، این تابع نیز ایمن نیست زیرا در صورت عدم پیکربندی صحیح PIC می تواند باعث رفتار نامشخص شود.
[`initialize`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html#method.initialize
اگر همه چیز خوب پیش برود ، باید هنگام اجرای `cargo run` پیام "It did not crash" را ببینیم.
## فعال‌سازی وقفه‌ها
تاکنون هیچ اتفاقی نیفتاده است زیرا وقفه‌ها همچنان در تنظیمات پردازنده غیرفعال هستند. این بدان معناست که پردازنده به هیچ وجه به کنترل کننده وقفه گوش نمی دهد، بنابراین هیچ وقفه ای نمی تواند به پردازنده برسد. بیایید این را تغییر دهیم:
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() };
x86_64::instructions::interrupts::enable(); // new
}
```
تابع `interrupts::enable` از کرت `x86_64` دستورالعمل خاص `sti` را اجرا می کند (“set interrupts”) تا وقفه های خارجی را فعال کند. اکنون وقتی `cargo run` را امتحان می کنیم ، می بینیم که یک خطای دوگانه رخ می‌دهد:
![QEMU printing `EXCEPTION: DOUBLE FAULT` because of hardware timer](qemu-hardware-timer-double-fault.png)
دلیل این خطای دوگانه این است که تایمر سخت افزاری (به طور دقیق تر [Intel 8253]) به طور پیش فرض فعال است، بنابراین به محض فعال کردن وقفه‌ها ، شروع به دریافت وقفه های تایمر می کنیم. از آنجا که هنوز یک تابع کنترل کننده برای آن تعریف نکرده‌ایم ، کنترل کننده خطای دوگانه فراخوانی می شود.
[Intel 8253]: https://en.wikipedia.org/wiki/Intel_8253
## مدیریت وقفه‌های تایمر
همانطور که در شکل [بالا](#the-8259-pic) می بینیم، تایمر از خط 0 از PIC اصلی استفاده می کند. این به این معنی است که به صورت وقفه 32 (0 + افست 32) به پردازنده می رسد. به جای هارد-کد(Hardcode) کردن 32، آن را در یک اینام(enum) به نام `InterruptIndex` ذخیره می کنیم:
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
```
اینام یک [اینام C مانند] است بنابراین ما می توانیم ایندکس را برای هر نوع به طور مستقیم مشخص کنیم. ویژگی `repr(u8)` مشخص می کند که هر نوع به عنوان `u8` نشان داده می شود. در آینده انواع بیشتری برای وقفه های دیگر اضافه خواهیم کرد.
[اینام C مانند]: https://doc.rust-lang.org/reference/items/enumerations.html#custom-discriminant-values-for-fieldless-enumerations
اکنون می توانیم یک تابع کنترل کننده برای وقفه تایمر اضافه کنیم:
```rust
// in src/interrupts.rs
use crate::print;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
idt[InterruptIndex::Timer.as_usize()]
.set_handler_fn(timer_interrupt_handler); // new
idt
};
}
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
}
```
`timer_interrupt_handler` ما دارای امضای مشابه کنترل کننده های استثنای ما است ، زیرا پردازنده به طور یکسان به استثناها و وقفه های خارجی واکنش نشان می دهد (تنها تفاوت این است که برخی از استثناها کد خطا را در پشته ذخیره می‌کنند). ساختمان [`InterruptDescriptorTable`] تریت [`IndexMut`] را پیاده سازی می کند، بنابراین می توانیم از طریق سینتکس ایندکس‌دهی آرایه، به ایتم های جداگانه دسترسی پیدا کنیم.
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[`IndexMut`]: https://doc.rust-lang.org/core/ops/trait.IndexMut.html
در کنترل کننده وقفه تایمر، یک نقطه را روی صفحه چاپ می کنیم. همانطور که وقفه تایمر به صورت دوره ای اتفاق می افتد ، انتظار داریم که در هر تیک تایمر یک نقطه ظاهر شود. با این حال، هنگامی که آن را اجرا می کنیم می بینیم که فقط یک نقطه چاپ می شود:
![QEMU printing only a single dot for hardware timer](qemu-single-dot-printed.png)
### پایان وقفه
دلیل این امر این است که PIC انتظار دارد یک سیگنال صریح "پایان وقفه" (EOI) از کنترل کننده وقفه ما دریافت کند. این سیگنال به PIC می گوید که وقفه پردازش شده و سیستم آماده دریافت وقفه بعدی است. بنابراین PIC فکر می کند ما هنوز مشغول پردازش وقفه تایمر اول هستیم و قبل از ارسال سیگنال بعدی با صبر و حوصله منتظر سیگنال EOI از ما هست.
برای ارسال EOI ، ما دوباره از ساختمان ثابت `PICS` خود استفاده می کنیم:
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
```
`notify_end_of_interrupt` تشخیص می‌دهد که PIC اصلی یا ثانویه وقفه را ارسال کرده است و سپس از پورت های `command` و `data` برای ارسال سیگنال EOI به PIC های مربوطه استفاده می کند. اگر PIC ثانویه وقفه را ارسال کرد ، هر دو PIC باید مطلع شوند زیرا PIC ثانویه به یک خط ورودی از PIC اصلی متصل است.
ما باید مراقب باشیم که از شماره بردار وقفه صحیح استفاده کنیم، در غیر این صورت می توانیم به طور تصادفی یک وقفه مهم ارسال نشده را حذف کنیم یا باعث هنگ سیستم خود شویم. این دلیل آن است که تابع ناامن است.
اکنون هنگامی که `cargo run` را اجرا می کنیم، نقاطی را می بینیم که به صورت دوره ای روی صفحه ظاهر می شوند:
![QEMU printing consecutive dots showing the hardware timer](qemu-hardware-timer-dots.gif)
### پیکربندی تایمر
تایمر سخت افزاری که ما از آن استفاده می کنیم ، _Programmable Interval Timer_ یا به اختصار PIT نامیده می شود. همانطور که از نام آن مشخص است ، می توان فاصله بین دو وقفه را پیکربندی کرد. ما در اینجا به جزئیات نمی پردازیم زیرا به زودی به [تایمر APIC] سوییچ خواهیم کرد، اما ویکی OSDev مقاله مفصلی درباره [پیکربندی PIT] دارد.
[تایمر APIC]: https://wiki.osdev.org/APIC_timer
[پیکربندی PIT]: https://wiki.osdev.org/Programmable_Interval_Timer
## بن‌بست ها
اکنون نوعی همروندی در هسته خود داریم: وقفه های تایمر به صورت ناهمزمان اتفاق می افتند ، بنابراین می توانند تابع `start_` را در هر زمان قطع کنند. خوشبختانه سیستم مالکیت راست از بسیاری از مشکلات مربوط به همروندی در زمان کامپایل جلوگیری می کند. یک استثنا قابل توجه بن‌بست است. درصورتی که نخ(Thread) بخواهد قفلی را بدست آورد که هرگز آزاد نخواهد شد، بن‌بست به وجود می آید. بنابراین نخ به طور نامحدود هنگ می‌کند.
ما می توانیم در هسته خود بن‌بست ایجاد کنیم. اگر به یاد داشته باشید، ماکرو `println` ما تابع `vga_buffer::_print` را فراخوانی می کند، که با استفاده از spinlock یک [`WRITER` گلوبال را قفل میکند][vga spinlock].
[vga spinlock]: @/edition-2/posts/03-vga-text-buffer/index.md#spinlocks
```rust
// in src/vga_buffer.rs
[]
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
`WRITER` را قفل می کند، `write_fmt` را روی آن فراخوانی می کند و در انتهای تابع به طور ضمنی قفل آن را باز می کند. حال تصور کنید که در حالی که `WRITER` قفل شده است وقفه رخ دهد و کنترل کننده وقفه نیز سعی کند چیزی را چاپ کند:
Timestep | _start | interrupt_handler
---------|------|------------------
0 | calls `println!` | &nbsp;
1 | `print` locks `WRITER` | &nbsp;
2 | | **interrupt occurs**, handler begins to run
3 | | calls `println!` |
4 | | `print` tries to lock `WRITER` (already locked)
5 | | `print` tries to lock `WRITER` (already locked)
… | | …
_never_ | _unlock `WRITER`_ |
`WRITER` قفل شده است ، بنابراین کنترل کننده وقفه منتظر می ماند تا آزاد شود. اما این هرگز اتفاق نمی افتد ، زیرا تابع `start_` فقط پس از بازگشت کنترل کننده وقفه ادامه می یابد. بنابراین کل سیستم هنگ است.
### ایجاد بن‌بست
ما می توانیم با چاپ چیزی در حلقه در انتهای تابع `start_` خود ، به راحتی چنین بن‌بست‌ای در هسته خود ایجاد کنیم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
loop {
use blog_os::print;
print!("-"); // new
}
}
```
وقتی آن را در QEMU اجرا می کنیم ، خروجی به حالت زیر دریافت می‌کنیم:
![QEMU output with many rows of hyphens and no dots](./qemu-deadlock.png)
می بینیم که فقط تعداد محدودی خط فاصله ، تا زمانی که وقفه تایمر اول اتفاق بیفتد، چاپ می شود. سپس سیستم هنگ می‌کند زیرا تایمر هنگام تلاش برای چاپ یک نقطه باعث بن‌بست می‌شود. به همین دلیل است که در خروجی فوق هیچ نقطه‌ای مشاهده نمی‌کنیم.
تعداد واقعی خط فاصله بین هر اجرا متفاوت است زیرا وقفه تایمر به صورت غیر همزمان انجام می شود. این عدم قطعیت، اشکال زدایی اشکالات مربوط به همروندی را بسیار دشوار می کند.
### رفع بن‌بست
برای جلوگیری از این بن‌بست ، تا زمانی که `Mutex` قفل شده باشد، می توانیم وقفه‌ها را غیرفعال کنیم:
```rust
// in src/vga_buffer.rs
/// Prints the given formatted string to the VGA text buffer
/// through the global `WRITER` instance.
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // new
interrupts::without_interrupts(|| { // new
WRITER.lock().write_fmt(args).unwrap();
});
}
```
تابع [`without_interrupts`] یک [کلوژر] را گرفته و آن را در یک محیط بدون وقفه اجرا می کند. ما از آن استفاده می کنیم تا اطمینان حاصل کنیم که تا زمانی که `Mutex` قفل شده است ، هیچ وقفه ای رخ نمی دهد. اکنون هنگامی که هسته را اجرا می کنیم ، می بینیم که آن بدون هنگ کردن به کار خود ادامه می دهد. (ما هنوز هیچ نقطه ای را مشاهده نمی کنیم ، اما این به این دلیل است که سرعت حرکت آنها بسیار سریع است. سعی کنید سرعت چاپ را کم کنید، مثلاً با قرار دادن `for _ in 0..10000 {}` در داخل حلقه.)
[`without_interrupts`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/interrupts/fn.without_interrupts.html
[کلوژر]: https://doc.rust-lang.org/book/second-edition/ch13-01-closures.html
ما می توانیم همین تغییر را در تابع چاپ سریال نیز اعمال کنیم تا اطمینان حاصل کنیم که هیچ بن‌بستی در آن رخ نمی دهد:
```rust
// in src/serial.rs
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // new
interrupts::without_interrupts(|| { // new
SERIAL1
.lock()
.write_fmt(args)
.expect("Printing to serial failed");
});
}
```
توجه داشته باشید که غیرفعال کردن وقفه‌ها نباید یک راه حل کلی باشد. مشکل این است که بدترین حالت تأخیر در وقفه را افزایش می دهد ، یعنی زمانی که سیستم به وقفه واکنش نشان می دهد. بنابراین وقفه‌ها باید فقط برای مدت زمان کوتاه غیرفعال شوند.
## رفع وضعیت رقابتی
اگر `cargo test` را اجرا کنید ، ممکن است ببینید تست `test_println_output` با شکست مواجه می‌شود:
```
> cargo test --lib
[…]
Running 4 tests
test_breakpoint_exception...[ok]
test_println... [ok]
test_println_many... [ok]
test_println_output... [failed]
Error: panicked at 'assertion failed: `(left == right)`
left: `'.'`,
right: `'S'`', src/vga_buffer.rs:205:9
```
دلیل آن وجود یک _وضعیت رقابتی_ بین تست و کنترل کننده تایمر ماست. اگر به یاد داشته باشید ، تست به این شکل است:
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
let s = "Some test string that fits on a single line";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}
```
این تست یک رشته را در بافر VGA چاپ می کند و سپس با پیمایش دستی روی آرایه `buffer_chars` خروجی را بررسی می کند. وضعیت رقابتی رخ می دهد زیرا ممکن است کنترل کننده وقفه تایمر بین `println` و خواندن کاراکتر های صفحه اجرا شود. توجه داشته باشید که این یک رقابت داده(Data race) خطرناک نیست، که Rust در زمان کامپایل کاملاً از آن جلوگیری کند. برای جزئیات به [_Rustonomicon_][nomicon-races] مراجعه کنید.
[nomicon-races]: https://doc.rust-lang.org/nomicon/races.html
برای رفع این مشکل ، باید `WRITER` را برای مدت زمان کامل تست قفل نگه داریم ، به این ترتیب که کنترل کننده تایمر نمی تواند `.` را روی صفحه نمایش در میان کار تست بنویسد. تست اصلاح شده به این شکل است:
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
use core::fmt::Write;
use x86_64::instructions::interrupts;
let s = "Some test string that fits on a single line";
interrupts::without_interrupts(|| {
let mut writer = WRITER.lock();
writeln!(writer, "\n{}", s).expect("writeln failed");
for (i, c) in s.chars().enumerate() {
let screen_char = writer.buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
});
}
```
ما تغییرات زیر را انجام دادیم:
- ما با استفاده صریح از متد `()lock` ، نویسنده را برای کل تست قفل می کنیم. به جای `println` ، از ماکرو [`writeln`] استفاده می کنیم که امکان چاپ بر روی نویسنده قبلاً قفل شده را فراهم می کند.
- برای جلوگیری از یک بن‌بست دیگر ، وقفه‌ها را برای مدت زمان تست غیرفعال می کنیم. در غیر این صورت ممکن است تست در حالی که نویسنده هنوز قفل است قطع شود.
- از آنجا که کنترل کننده وقفه تایمر هنوز می تواند قبل از تست اجرا شود ، قبل از چاپ رشته `s` یک خط جدید `n\` اضافی چاپ می کنیم. به این ترتیب ، اگر که کنترل کننده تایمر تعدادی کاراکتر `.` را در خط فعلی چاپ کرده باشد، از شکست تست جلوگیری می کنیم.
[`writeln`]: https://doc.rust-lang.org/core/macro.writeln.html
اکنون با تغییرات فوق ، `cargo test` دوباره با قطعیت موفق می شود.
این یک وضعیت رقابتی بسیار بی خطر بود که فقط باعث شکست تست می‌شد. همانطور که می توانید تصور کنید، اشکال زدایی سایر وضعیت‌های رقابتی به دلیل ماهیت غیر قطعی بودن آنها بسیار دشوارتر است. خوشبختانه، راست مانع از رقابت داده‌ها می شود ، که جدی‌ترین نوع وضعیت رقابتی است ، زیرا می تواند باعث انواع رفتارهای تعریف نشده ، از جمله کرش کردن سیستم و خراب شدن آرام و بی صدای حافظه شود.
## دستورالعمل `hlt`
تاکنون از یک حلقه خالی ساده در پایان توابع `start_` و` panic` استفاده می کردیم. این باعث می شود پردازنده به طور بی وقفه بچرخد و بنابراین مطابق انتظار عمل می کند. اما بسیار ناکارآمد است، زیرا پردازنده همچنان با سرعت کامل کار می کند حتی اگر کاری برای انجام نداشته باشد. هنگامی که هسته را اجرا می کنید می توانید این مشکل را در مدیر وظیفه خود مشاهده کنید: فرایند QEMU در کل مدت زمان نیاز به تقریباً 100٪ پردازنده دارد.
کاری که واقعاً می خواهیم انجام دهیم این است که پردازنده را تا رسیدن وقفه بعدی متوقف کنیم. این اجازه می دهد پردازنده وارد حالت خواب شود که در آن انرژی بسیار کمتری مصرف می کند. [دستورالعمل `hlt`] دقیقاً همین کار را می کند. بیایید از این دستورالعمل برای ایجاد یک حلقه بی پایان با مصرف انرژی پایین استفاده کنیم:
[دستورالعمل `hlt`]: https://en.wikipedia.org/wiki/HLT_(x86_instruction)
```rust
// in src/lib.rs
pub fn hlt_loop() -> ! {
loop {
x86_64::instructions::hlt();
}
}
```
تابع `instructions::hlt` فقط یک [پوشش نازک] بر روی دستورالعمل اسمبلی است. این بی خطر است زیرا به هیچ وجه نمی تواند ایمنی حافظه را به خطر بیندازد.
[پوشش نازک]: https://github.com/rust-osdev/x86_64/blob/5e8e218381c5205f5777cb50da3ecac5d7e3b1ab/src/instructions/mod.rs#L16-L22
اکنون می توانیم از این `hlt_loop` به جای حلقه های بی پایان در توابع` start_` و `panic` استفاده کنیم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
println!("It did not crash!");
blog_os::hlt_loop(); // new
}
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
blog_os::hlt_loop(); // new
}
```
بیایید `lib.rs` را نیز به روز کنیم:
```rust
// in src/lib.rs
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init();
test_main();
hlt_loop(); // new
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
hlt_loop(); // new
}
```
اکنون وقتی هسته خود را در QEMU اجرا می کنیم ، شاهد استفاده بسیار کمتری از پردازنده هستیم.
## ورودی صفحه کلید
اکنون که قادر به مدیریت وقفه های دستگاه های خارجی هستیم ، سرانجام قادر به پشتیبانی از ورودی صفحه کلید هستیم. این به ما امکان می دهد برای اولین بار با هسته خود تعامل داشته باشیم.
<aside class="post_aside">
توجه داشته باشید که ما فقط نحوه مدیریت صفحه کلیدهای [PS/2] را توضیح می دهیم ، نه صفحه کلیدهای USB. هر چند که، مادربورد صفحه کلیدهای USB را به عنوان دستگاه های PS/2 برای پشتیبانی از نرم افزارهای قدیمی تقلید می‌کند ، بنابراین تا زمانی که هسته ما از USB پشتیبانی نمی‌کند می توانیم با اطمینان از صفحه کلیدهای USB چشم پوشی کنیم.
</aside>
[PS/2]: https://en.wikipedia.org/wiki/PS/2_port
مانند تایمر سخت افزاری ، کنترل کننده صفحه کلید نیز به طور پیش فرض از قبل فعال شده است. بنابراین با فشار دادن یک کلید ، کنترل کننده صفحه کلید وقفه را به PIC ارسال می کند و آن را به پردازنده منتقل می کند. پردازنده به دنبال یک تابع کنترل کننده در IDT می‌گردد ، اما ایتم مربوطه خالی است. بنابراین یک خطای دوگانه رخ می دهد.
پس بیایید یک تایع کنترل کننده برای وقفه صفحه کلید اضافه کنیم. این کاملاً مشابه نحوه تعریف کنترل کننده برای وقفه تایمر است ، فقط از یک شماره وقفه متفاوت استفاده می کند:
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
Keyboard, // new
}
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
// new
idt[InterruptIndex::Keyboard.as_usize()]
.set_handler_fn(keyboard_interrupt_handler);
idt
};
}
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!("k");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
همانطور که در شکل [بالا](#the-8259-pic) مشاهده می کنیم ، صفحه کلید از خط 1 در PIC اصلی استفاده می کند. این به این معنی است که به صورت وقفه 33 (1 + افست 32) به پردازنده می رسد. ما این ایندکس را به عنوان یک نوع جدید `Keyboard` به ای‌نام `InterruptIndex` اضافه می کنیم. نیازی نیست که مقدار را صریحاً مشخص کنیم ، زیرا این مقدار به طور پیش فرض برابر مقدار قبلی بعلاوه یک که 33 نیز می باشد ، هست. در کنترل کننده وقفه ، ما یک `k` چاپ می کنیم و سیگنال پایان وقفه را به کنترل کننده وقفه می فرستیم.
اکنون می بینیم که وقتی کلید را فشار می دهیم `k` بر روی صفحه ظاهر می شود. با این حال ، این فقط برای اولین کلیدی که فشار می دهیم کار می کند ، حتی اگر به فشار دادن کلیدها ادامه دهیم ، دیگر `k` بر روی صفحه نمایش ظاهر نمی شود. این امر به این دلیل است که کنترل کننده صفحه کلید تا زمانی که اصطلاحاً _scancode_ را نخوانیم ، وقفه دیگری ارسال نمی کند.
### خواندن اسکن‌کد ها
برای اینکه بفهمیم _کدام_ کلید فشار داده شده است ، باید کنترل کننده صفحه کلید را جستجو کنیم. ما این کار را با خواندن از پورت داده کنترل کننده PS/2 ، که [پورت ورودی/خروجی] با شماره `0x60` است ، انجام می دهیم:
[پورت ورودی/خروجی]: @/edition-2/posts/04-testing/index.md#i-o-ports
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
print!("{}", scancode);
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
ما برای خواندن یک بایت از پورت داده صفحه کلید از نوع [`Port`] کرت `x86_64` استفاده می‌کنیم. این بایت [_اسکن کد_] نامیده می شود و عددی است که کلید فشرده شده / رها شده را نشان می دهد. ما هنوز کاری با اسکن کد انجام نمی دهیم ، فقط آن را روی صفحه چاپ می کنیم:
[`Port`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/port/struct.Port.html
[_اسکن کد_]: https://en.wikipedia.org/wiki/Scancode
![QEMU printing scancodes to the screen when keys are pressed](qemu-printing-scancodes.gif)
تصویر بالا نشان می دهد که من آرام آرام "123" را تایپ می کنم. می بینیم که کلیدهای مجاور دارای اسکن کد مجاور هستند و فشار دادن یک کلید دارای اسکن کد متفاوت با رها کردن آن است. اما چگونه اسکن‌کدها را دقیقاً به کار اصلی آن کلید ترجمه کنیم؟
### تفسیر اسکن‌کد ها
سه استاندارد مختلف برای نگاشت بین اسکن کدها و کلیدها وجود دارد ، اصطلاحاً _مجموعه های اسکن کد_. هر سه به صفحه کلید رایانه های اولیه IBM برمی گردند: [IBM XT] ، [IBM 3270 PC] و [IBM AT]. خوشبختانه رایانه های بعدی روند تعریف مجموعه های جدید اسکن کد را ادامه ندادند ، بلکه مجموعه های موجود را تقلید و آنها را گسترش دادند. امروزه بیشتر صفحه کلیدها را می توان به گونه ای پیکربندی کرد که از هر کدام از سه مجموعه تقلید کند.
[IBM XT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer_XT
[IBM 3270 PC]: https://en.wikipedia.org/wiki/IBM_3270_PC
[IBM AT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer/AT
به طور پیش فرض ، صفحه کلیدهای PS/2 مجموعه شماره 1 ("XT") را تقلید می کنند. در این مجموعه ، 7 بیت پایین بایت اسکن‌کد، کلید را تعریف می کند و مهمترین بیت فشردن ("0") یا رها کردن ("1") را مشخص می کند. کلیدهایی که در صفحه کلید اصلی [IBM XT] وجود نداشتند ، مانند کلید enter روی کی‌پد ، دو اسکن کد به طور متوالی ایجاد می کنند: یک بایت فرار(escape) `0xe0` و سپس یک بایت نمایانگر کلید. برای مشاهده لیست تمام اسکن‌کدهای مجموعه 1 و کلیدهای مربوط به آنها ، [ویکی OSDev][scancode set 1] را مشاهده کنید.
[scancode set 1]: https://wiki.osdev.org/Keyboard#Scan_Code_Set_1
برای ترجمه اسکن کدها به کلیدها ، می توانیم از عبارت match استفاده کنیم:
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
// new
let key = match scancode {
0x02 => Some('1'),
0x03 => Some('2'),
0x04 => Some('3'),
0x05 => Some('4'),
0x06 => Some('5'),
0x07 => Some('6'),
0x08 => Some('7'),
0x09 => Some('8'),
0x0a => Some('9'),
0x0b => Some('0'),
_ => None,
};
if let Some(key) = key {
print!("{}", key);
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
کد بالا فشردن کلیدهای عددی 9-0 را ترجمه کرده و کلیه کلیدهای دیگر را نادیده می گیرد. از عبارت [match] برای اختصاص یک کاراکتر یا `None` به هر اسکن کد استفاده می کند. سپس با استفاده از [`if let`] اپشن `key` را از بین می برد. با استفاده از همان نام متغیر `key` در الگو که یک روش معمول برای از بین بردن انواع`Option` در راست است تعریف قبلی را [سایه می زنیم].
[match]: https://doc.rust-lang.org/book/ch06-02-match.html
[`if let`]: https://doc.rust-lang.org/book/ch18-01-all-the-places-for-patterns.html#conditional-if-let-expressions
[سایه می زنیم]: https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing
اکنون می توانیم اعداد را بنویسیم:
![QEMU printing numbers to the screen](qemu-printing-numbers.gif)
ترجمه کلیدهای دیگر نیز به همین روش کار می کند. خوشبختانه کرت ای با نام [`pc-keyboard`] برای ترجمه اسکن‌کد مجموعه های اسکن‌کد 1 و 2 وجود دارد ، بنابراین لازم نیست که خودمان این را پیاده سازی کنیم. برای استفاده از کرت ، آن را به `Cargo.toml` اضافه کرده و در`lib.rs` خود وارد می کنیم:
[`pc-keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/
```toml
# in Cargo.toml
[dependencies]
pc-keyboard = "0.5.0"
```
اکنون میتوانیم از این کرت برای باز نویسی `keyboard_interrupt_handler` استفاده کنیم:
```rust
// in/src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
use x86_64::instructions::port::Port;
lazy_static! {
static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(layouts::Us104Key, ScancodeSet1,
HandleControl::Ignore)
);
}
let mut keyboard = KEYBOARD.lock();
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => print!("{}", character),
DecodedKey::RawKey(key) => print!("{:?}", key),
}
}
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
ما از ماکرو `lazy_static` برای ایجاد یک شی ثابت [`Keyboard`] محافظت شده توسط Mutex استفاده می کنیم. `Keyboard` را با طرح صفحه کلید ایالات متحده و مجموعه اسکن کد 1 مقداردهی می کنیم. پارامتر [`HandleControl`] اجازه می دهد تا `ctrl+[a-z]` را به کاراکتر های `U+0001` تا `U+001A` نگاشت کنیم. ما نمی خواهیم چنین کاری انجام دهیم ، بنابراین از گزینه `Ignore` برای برخورد با `ctrl` مانند کلیدهای عادی استفاده می کنیم.
[`HandleControl`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/enum.HandleControl.html
در هر وقفه ، Mutex را قفل می کنیم ، اسکن کد را از کنترل کننده صفحه کلید می خوانیم و آن را به متد [`add_byte`] منتقل می کنیم ، که اسکن کد را به یک `<Option<KeyEvent` ترجمه می کند. [`KeyEvent`] حاوی كلیدی است كه باعث رویداد شده و آیا این یک رویداد فشردن یا رها کردن بوده است.
[`Keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html
[`add_byte`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.add_byte
[`KeyEvent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.KeyEvent.html
برای تفسیر این رویداد کلید ، آن را به متد [`process_keyevent`] منتقل می کنیم ، که در صورت امکان رویداد کلید را به یک کاراکتر ترجمه می کند. به عنوان مثال ، بسته به فشردن کلید shift ، یک رویداد فشردن کلید `A` را به یک حرف کوچک `a` یا یک حرف بزرگ `A` ترجمه می کند.
[`process_keyevent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.process_keyevent
با استفاده از این کنترل کننده وقفه اصلاح شده اکنون می توانیم متن بنویسیم:
![Typing "Hello World" in QEMU](qemu-typing.gif)
### پیکربندی صفحه کلید
امکان پیکربندی برخی از جنبه های صفحه کلید PS/2 وجود دارد، به عنوان مثال از کدام اسکن کد استفاده کند. ما در اینجا به آن نمی پردازیم زیرا این پست به اندازه کافی طولانی شده است ، اما ویکی OSDev دارای نمای کلی [دستورات پیکربندی] ممکن است.
[دستورات پیکربندی]: https://wiki.osdev.org/PS/2_Keyboard#Commands
## خلاصه
در این پست نحوه فعال سازی و مدیریت وقفه های خارجی توضیح داده شد. ما در مورد 8259 PIC و طرح اولیه/ثانویه آن ، نگاشت دوباره اعداد وقفه و سیگنال "پایان وقفه" اطلاعات کسب کردیم. ما کنترل کننده‌هایی را برای تایمر سخت افزاری و صفحه کلید پیاده سازی کردیم و درباره دستورات `hlt` اطلاعاتی کسب کردیم که پردازنده را تا وقفه بعدی متوقف می کند.
اکنون ما قادر به تعامل با هسته خود هستیم و برخی از عناصر اساسی برای ایجاد یک پوسته(Shell) کوچک یا بازی های ساده را داریم.
## مرحله بعدی چیست؟
وقفه های تایمر برای یک سیستم عامل ضروری است، زیرا راهی برای قطع دوره ای روند اجرا و کنترل مجدد هسته فراهم می کند. سپس هسته می تواند به فرایند دیگری سوییچ کند و این توهم را ایجاد کند که چندین فرآیند به طور موازی اجرا می شوند.
اما قبل از اینکه بتوانیم فرایندها یا نخ‌ها را ایجاد کنیم ، به روشی برای اختصاص حافظه برای آنها نیاز داریم. در پست های بعدی، مدیریت حافظه مورد بررسی قرار می گیرد تا این عنصر اساسی ایجاد شود.

View File

@@ -1,740 +0,0 @@
+++
title = "ハードウェア割り込み"
weight = 7
path = "ja/hardware-interrupts"
date = 2018-10-22
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "81d4f49f153eb5f390681f5c13018dd2aa6be0b1"
# GitHub usernames of the people that translated this post
translators = ["shimomura1004", "woodyZootopia"]
+++
この記事では、ハードウェア割り込みを正しく CPU に転送するためにプログラム可能な割り込みコントローラの設定を行います。これらの割り込みに対処するため、例外ハンドラのときに行ったのと同じように割り込み記述子表に新しいエントリを追加しなくてはいけません。ここでは周期タイマ割り込みの受け方と、キーボードからの入力の受け方を学びます。
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-07` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-07
<!-- toc -->
## 概要
割り込みは、接続されたハードウェアデバイスから CPU に通知を行う方法を提供します。よって、新しい文字がないか定期的にカーネルにキーボードを確認させるかわりに ([ポーリング][_polling_]と呼ばれます)、キー入力のたびにキーボードのほうからカーネルに通知することができます。この方法の場合、カーネルはなにかが起きたときだけ処置を行えばよいので、とても効率がいいです。カーネルは次のポーリングのタイミングを待たずとも即座に反応することができるので、応答時間も短くなります。
[_polling_]: https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%BC%E3%83%AA%E3%83%B3%E3%82%B0_(%E6%83%85%E5%A0%B1)
すべてのハードウェアを直接 CPU に接続することはできません。そのかわりに、独立した _割り込みコントローラ_ がすべてのデバイスからの割り込みを取りまとめて CPU に通知します:
```
____________ _____
Timer ------------> | | | |
Keyboard ---------> | Interrupt |---------> | CPU |
Other Hardware ---> | Controller | |_____|
Etc. -------------> |____________|
```
多くの割り込みコントローラはプログラム可能です。これはそれぞれの割り込みに対して異なる優先度を設定することができるということです。例えば正確な時間管理を保証するために、キーボード割り込みよりもタイマ割り込みに高い優先度を設定することができます。
例外とは異なり、ハードウェア割り込みは _非同期的_ です。これは、ハードウェア割り込みは実行コードからは完全に独立していて、どんなタイミングでもハードウェア割り込みが発生する可能性があるということです。よって突如として私達のカーネルはある種の並行性を持つようになり、これにより並行処理に関するあらゆる潜在的なバグを持つことになります。Rust の厳格な所有権モデルはグローバルな状態を変更可能にすることを禁止しているため、この問題に役立ちます。ただしこの記事の後半で見るとおり、それでもデッドロックは発生してしまいます。
## 8259 PIC
[Intel 8259] は 1976 年に登場したプログラム可能な割り込みコントローラ (programmable interrupt controller: PIC) です。ずいぶん前に、より新しい [APIC] によって置き換えられましたが、そのインタフェースは現在のシステムでも後方互換性のためにサポートされています。8259 PIC は APIC よりも設定がかなり簡単なので、後の記事で APIC に切り替える前に、8259 PIC を使って割り込み処理に入門することにしましょう。
[APIC]: https://ja.wikipedia.org/wiki/APIC
8259 PIC は、割り込み線8本と、CPU と通信するための線を数本持っています。当時の典型的なシステムは 8259 PIC をプライマリとセカンダリの2つ搭載しており、セカンダリの PIC はプライマリの PIC の割り込み線のひとつに接続されていました:
[Intel 8259]: https://ja.wikipedia.org/wiki/Intel_8259
```
____________ ____________
Real Time Clock --> | | Timer -------------> | |
ACPI -------------> | | Keyboard-----------> | | _____
Available --------> | Secondary |----------------------> | Primary | | |
Available --------> | Interrupt | Serial Port 2 -----> | Interrupt |---> | CPU |
Mouse ------------> | Controller | Serial Port 1 -----> | Controller | |_____|
Co-Processor -----> | | Parallel Port 2/3 -> | |
Primary ATA ------> | | Floppy disk -------> | |
Secondary ATA ----> |____________| Parallel Port 1----> |____________|
```
この図は典型的な割り込み線の割り当てを示しています。15本の線の多くは割り当てが固定されています。例えば、セカンダリの PIC の4番目の線はマウスに割り当てられています。
それぞれのコントローラは、"コマンド" ポートと "データ" ポートという2つの [I/O ポート][I/O ports] を通じて設定を行うことができます。プライマリコントローラでは、これらのポートは `0x20` (コマンド) と `0x21` (データ) になります。セカンダリコントローラでは、`0xa0` (コマンド) と `0xa1` (データ) になります。PIC の設定方法の詳細は [osdev.org の記事][article on osdev.org]を見てください。
[I/O ports]: @/edition-2/posts/04-testing/index.md#i-o-ports
[article on osdev.org]: https://wiki.osdev.org/8259_PIC
### 実装
PIC のデフォルト設定では、0から15の割り込みベクタ番号を CPU に送るようになっているため使うことができません。これらの番号は既に CPU 例外で使われており、例えば8番はダブルフォルトに対応します。この重複による問題を解決するためには PIC の割り込みを別の番号にマッピングし直さないといけません。割り込み番号の範囲は例外と重複しなければ問題になりませんが、32個の例外スロットのあとの最初の空き番号である32から47の範囲がよく使われます。
コマンドポートとデータポートに特別な値を書き込むことで PIC の設定を行います。幸い [`pic8259`] というクレートが既にありますので、初期化シーケンスを自分たちで書く必要はありません。クレートの動作に興味があるなら[ソースコード][pic crate source]を確認してみてください。とても小さくドキュメントも豊富です。
[pic crate source]: https://docs.rs/crate/pic8259/0.10.1/source/src/lib.rs
クレートを依存関係を追加するため、以下の内容をプロジェクトに追加します:
[`pic8259`]: https://docs.rs/pic8259/0.10.1/pic8259/
```toml
# in Cargo.toml
[dependencies]
pic8259 = "0.10.1"
```
このクレートが提供する主な抽象化は、上で見たようなプライマリとセカンダリの PIC からなるレイアウトを表わす [`ChainedPics`] 構造体です。これは以下のように使うように設計されています:
[`ChainedPics`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html
```rust
// in src/interrupts.rs
use pic8259::ChainedPics;
use spin;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
```
上で述べたように、PIC のオフセットを32から47の範囲に設定しています。`ChainedPics` 構造体を `Mutex` でラップすることで、次のステップで必要になる安全な可変アクセスを ([`lock` メソッド][spin mutex lock]を使って) 得ることができます。間違ったオフセットを指定すると未定義動作となるため、`ChainedPics::new` 関数は unsafe です。
[spin mutex lock]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html#method.lock
8259 PIC の初期化は `init` 関数内で行うことができます:
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() }; // new
}
```
PIC の初期化を行うために [`initialize`] 関数を使います。`ChainedPics::new` 関数と同じように、PIC を間違って設定すると未定義動作となるため、この関数も unsafe になります。
[`initialize`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html#method.initialize
すべてうまくいけば、`cargo run` を実行すると "It did not crash" というメッセージが引き続き表示されるはずです。
## 割り込みの有効化
CPU の設定で割り込みが無効化されていたため、これまではなにも起きませんでした。これは CPU が割り込みコントローラからの信号をすべて無視していたため、割り込みが CPU に届かなかったということです。これを変更しましょう:
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() };
x86_64::instructions::interrupts::enable(); // new
}
```
`x86_64` クレートの `interrupts::enable` 関数は、特別な `sti` ("set interrupts") 命令を実行し外部割り込みを有効にします。ここで `cargo run` を実行するとダブルフォルトが発生します:
![QEMU printing `EXCEPTION: DOUBLE FAULT` because of hardware timer](qemu-hardware-timer-double-fault.png)
ダブルフォルトが発生する理由は、ハードウェアタイマ (正確には [Intel 8253]) がデフォルトで有効になっているため、割り込みを有効にするとすぐにタイマ割り込みを受け取り始めるためです。この割り込みのためのハンドラ関数を定義していないため、ダブルフォルトのハンドラが呼ばれることになります。
[Intel 8253]: https://en.wikipedia.org/wiki/Intel_8253
## タイマ割り込みの処理
[上述](#8259-pic)した図にある通り、タイマはプライマリの PIC の0番目の線を使います。これはタイマ割り込みは32番 (0 + オフセットの32) の割り込みとして CPU に届くということです。32をハードコーディングする代わりに `InterruptIndex` enum に保存することにしましょう:
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
```
Rust の enum は [C 言語ライクな enum][C-like enum] であるため、各ヴァリアントに直接インデックスを指定できます。 `repr(u8)` アトリビュートは、各ヴァリアントが `u8` 型で表されるよう指定しています。今後、他の例外に対してヴァリアントを追加していきます。
[C-like enum]: https://doc.rust-lang.org/reference/items/enumerations.html#custom-discriminant-values-for-fieldless-enumerations
さて、タイマ割り込みへのハンドラ関数を追加していきます:
```rust
// in src/interrupts.rs
use crate::print;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
idt[InterruptIndex::Timer.as_usize()]
.set_handler_fn(timer_interrupt_handler); // new
idt
};
}
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
}
```
CPU は例外に対しても外部割り込みに対しても同じように反応するため、我々が定義した `timer_interrupt_handler` は例外ハンドラと同じシグニチャを持っています (唯一の違いは、一部の例外はエラーコードをプッシュすることです)。[`InterruptDescriptorTable`] 構造体は [`IndexMut`] トレイトを実装しているので、配列の添字記法でそれぞれのエントリにアクセスすることができます。
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[`IndexMut`]: https://doc.rust-lang.org/core/ops/trait.IndexMut.html
我々のタイマ割り込みハンドラでは画面にドットを表示します。タイマ割り込みは定期的に発生するので、タイマティックのたびに新たなドットが現れるだろうと思うでしょう。しかし実行してみると、ドットはひとつしか表示されません:
![QEMU printing only a single dot for hardware timer](qemu-single-dot-printed.png)
### 割り込みの終了
この理由は、PIC は明示的な "割り込みの終了" (end of interrupt: EOI) 信号を割り込みハンドラが通知してくると期待しているからです。この信号は、割り込みが既に処理されシステムが次の割り込みを受け取る準備ができたことをコントローラに伝えます。そのため PIC は、我々のシステムはまだ最初のタイマ割り込みを処理している最中だと考え、次の割り込みを送らずに辛抱強く EOI 信号を待ち続けているのです。
EOI を送るためには、再び静的な `PICS` 構造体を使います:
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
```
`notify_end_of_interrupt` は、プライマリとセカンダリのどちらの PIC が割り込みを送ったかを判断し、コマンドポートとデータポートを使って EOI 信号をそれぞれのコントローラに送ります。セカンダリの PIC はプライマリの PIC の入力線に接続されているため、もしセカンダリの PIC が割り込みを送った場合は、両方の PIC に信号を送る必要があります。
正しい割り込みベクタ番号を使うよう気をつけないと、まだ送信されていない重要な割り込みを間違って消してしまったり、システムがハングしてしまうかもしれません。この関数が unsafe になっているのはこのためです。
`cargo run` を実行すると、画面上にドットが定期的に表示されるでしょう:
![QEMU printing consecutive dots showing the hardware timer](qemu-hardware-timer-dots.gif)
### タイマの設定
我々が使ったハードウェアタイマは _プログラム可能インターバルタイマ_ 、もしくは短く PIT と呼ばれています。名前が示すように、PIT は2つの割り込みの間の間隔を設定することができます。すぐに [APIC タイマ][APIC timer]に切り替えるのでここで詳細に入ることはしませんが、OSDev wiki には [PIT の設定][configuring the PIT]に関する記事が豊富にあります。
[APIC timer]: https://wiki.osdev.org/APIC_timer
[configuring the PIT]: https://wiki.osdev.org/Programmable_Interval_Timer
## デッドロック
これで我々のカーネルはある種の並行性を持ちました: タイマ割り込みは非同期に発生するので、どんなタイミングでも `_start` 関数に割り込み得るのです。幸い、Rust の所有権システムは並行性に関連する多くのバグをコンパイル時に防ぐことができます。特筆すべき例外のひとつがデッドロックです。デッドロックはスレッドが決して解放されないロックを取得しようとしたときに起こり、そのスレッドは永遠にハングしてしまいます。
我々のカーネルでは、既にデッドロックが起きる可能性があります。我々が実装した `prinln` マクロは `vga_buffer::_print` 関数を呼び出しており、_print 関数はスピンロックを使って[グローバルな `WRITER` をロックする][vga spinlock]ということを思い出してください:
[vga spinlock]: @/edition-2/posts/03-vga-text-buffer/index.md#spinlocks
```rust
// in src/vga_buffer.rs
[]
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
_print 関数は `WRITER` をロックし、`write_fmt` を呼び出し、そして関数の最後で暗黙にロックを解放します。では、`WRITER` がロックされている間に割り込みが発生し、割り込みハンドラもなにかを表示しようとしていると想像してみてください:
時刻 | _start | 割り込みハンドラ
---------|------|------------------
0 | `println!` を呼び出す | &nbsp;
1 | `print``WRITER` をロック | &nbsp;
2 | | **割り込みが発生**、割り込みハンドラが動き出す
3 | | `println!` を呼び出す |
4 | | `print``WRITER` をロックしようとする (既にロック済み)
5 | | `print``WRITER` をロックしようとする (既にロック済み)
… | | …
_(決して起こらない)_ | _`WRITER` のロックを解放する_ |
`WRITER` はロックされているので、割り込みハンドラはそれが解放されるのを待ちます。しかし `_start` 関数は割り込みハンドラから処理が戻らないと実行されることはないので、ロックが解放されることはありません。このためシステム全体がハングしてしまいます。
### デッドロックを起こす
我々のカーネルでは、`_start` 関数の最後のループの中で何かを表示するだけで簡単にデッドロックを起こすことができます:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
loop {
use blog_os::print;
print!("-"); // new
}
}
```
QEMU で実行すると以下のような出力が得られます:
![QEMU output with many rows of hyphens and no dots](./qemu-deadlock.png)
限られた数のハイフンが表示されたのち、最初のタイマ割り込みが発生したことがわかります。そしてタイマ割り込みハンドラがドットを表示しようとするとデッドロックするので、システムがハングしてしまいます。これが上記の出力でドットが表示されていない理由です。
タイマ割り込みは非同期に発生するので、実際のハイフンの数は実行するたびに変わります。この非決定性が、並行性に関するバグのデバッグを非常に難しくします。
### デッドロックを修正する
このデッドロックを回避するため、`Mutex` がロックされている間は割り込みを無効化することができます:
```rust
// in src/vga_buffer.rs
/// グローバルな `WRITER` インスタンスを使って
/// フォーマット済み文字列を VGA テキストバッファに出力する
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // new
interrupts::without_interrupts(|| { // new
WRITER.lock().write_fmt(args).unwrap();
});
}
```
[`without_interrupts`] 関数は[クロージャ][closure]を引数に取り、これを割り込みが発生しない状態で実行します。これを使えば `Mutex` がロックされている間は割り込みが発生しないことを保証できます。このように修正したカーネルを実行すると、今度はハングせずに実行が続きます。(ドットがないように見えますが、これはスクロールが速すぎるためです。例えば `for _ in 0..10000 {}` をループ内で実行するなどで表示速度を遅くしてみてください。)
[`without_interrupts`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/interrupts/fn.without_interrupts.html
[closure]: https://doc.rust-lang.org/book/ch13-01-closures.html
シリアル出力関数でもデッドロックが起きないことを保証するために、同等の変更を加えます:
```rust
// in src/serial.rs
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // new
interrupts::without_interrupts(|| { // new
SERIAL1
.lock()
.write_fmt(args)
.expect("Printing to serial failed");
});
}
```
割り込みを無効化することを一般的な解決策としてはならないことは覚えておいてください。割り込みの無効化は、レイテンシ、つまりシステムが割り込みに反応するまでの時間の最悪値を増加させるという問題があります。そのため割り込みの無効化はごく短時間に限るべきです。
## 競合状態を修正する
`cargo test` を実行すると、`test_println_output` テストが失敗することが確認できるかもしれません:
```
> cargo test --lib
[…]
Running 4 tests
test_breakpoint_exception...[ok]
test_println... [ok]
test_println_many... [ok]
test_println_output... [failed]
Error: panicked at 'assertion failed: `(left == right)`
left: `'.'`,
right: `'S'`', src/vga_buffer.rs:205:9
```
この理由はテスト関数とタイマ割り込みハンドラの間の _競合状態_ です。テスト処理は以下のようになっていました:
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
let s = "Some test string that fits on a single line";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}
```
このテストでは、VGA バッファに文字列を出力したあと `buffer_chars` 配列を手動でひとつずつチェックしています。`println` 関数を実行したあと、表示された文字の読み取り処理を行うまでの間にタイマ割り込みハンドラが動作するかもしれず、このとき競合状態になります。ただ、これは危険な _データ競合_ ではないことに注意してください―― Rust はデータ競合をコンパイル時に完全に防ぐことができます。詳細は [_Rustonomicon_][nomicon-races] を参照してください。
[nomicon-races]: https://doc.rust-lang.org/nomicon/races.html
これを修正するため、タイマ割り込みハンドラがテストの途中で `.` を画面に出力できないように、テストが完了するまでの間は `WRITER` をロックし続ける必要があります。修正されたテストはこのようになります:
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
use core::fmt::Write;
use x86_64::instructions::interrupts;
let s = "Some test string that fits on a single line";
interrupts::without_interrupts(|| {
let mut writer = WRITER.lock();
writeln!(writer, "\n{}", s).expect("writeln failed");
for (i, c) in s.chars().enumerate() {
let screen_char = writer.buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
});
}
```
以下のような変更を行いました:
- `lock()` メソッドを明示的に使い、テスト実行中はずっと writer をロックし続けるようにします。`println` の代わりに、既にロックされた writer に表示を行うことができる [`writeln`] マクロを使います。
- 他のデッドロックを防ぐため、テスト実行中は割り込みを無効化します。そうでないと writer がロックされている間に割り込みが入ってきてしまうかもしれません。
- テスト実行前にタイマ割り込みハンドラが実行される可能性は依然としてあるので、文字列 `s` を出力する前に追加で改行文字 `\n` を出力するようにします。これにより、タイマハンドラが現在の行に既に出力した `.` 文字によってテストが失敗するのを避けています。
[`writeln`]: https://doc.rust-lang.org/core/macro.writeln.html
上記の変更によって、`cargo test` は再び必ず成功するようになります。
これはテストが失敗するだけの無害な競合状態でした。想像できると思いますが、他の競合状態はその非決定的な性質のためずっとデバッグが大変になり得ます。幸運なことに Rust は、システムのクラッシュや無兆候でのメモリ破壊を含むあらゆる種類の未定義動作を引き起こす最も深刻なタイプの競合状態であるデータ競合から我々を守ってくれます。
## `hlt` 命令
これまで我々は、`_start``panic` 関数の末尾で単純なループ文を使ってきました。これはずっと CPU を回し続けるので、期待通りに動作します。しかしこれはなにも仕事がない場合でも CPU が全速力で動作し続けることになるので、とても非効率です。カーネルを動かしているときにタスクマネージャを見ればこの問題がすぐに確認できるでしょう: QEMU のプロセスは、常時 CPU 時間のほぼ 100% を必要とします。
我々が本当にやりたいことは、次の割り込みが入るまで CPU を停止することです。これにより CPU はほとんど電力を使わないスリープ状態に入ることができます。[hlt 命令][`hlt` instruction]はまさにこれを行うものです。この命令を使ってエネルギー効率のいい無限ループを作ってみましょう:
[`hlt` instruction]: https://en.wikipedia.org/wiki/HLT_(x86_instruction)
```rust
// in src/lib.rs
pub fn hlt_loop() -> ! {
loop {
x86_64::instructions::hlt();
}
}
```
`instructions::hlt` 関数はアセンブリ命令の[薄いラッパ][thin wrapper]です。この命令はメモリ安全性を損なわないので unsafe ではありません。
[thin wrapper]: https://github.com/rust-osdev/x86_64/blob/5e8e218381c5205f5777cb50da3ecac5d7e3b1ab/src/instructions/mod.rs#L16-L22
これで `hlt_loop``_start``panic` 関数内の無限ループの代わりに使うことができます:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
println!("It did not crash!");
blog_os::hlt_loop(); // new
}
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
blog_os::hlt_loop(); // new
}
```
`lib.rs` も同様に更新しましょう:
```rust
// in src/lib.rs
/// `cargo test` のエントリポイント
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init();
test_main();
hlt_loop(); // new
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
hlt_loop(); // new
}
```
QEMU でカーネルを動かすと、CPU 時間の消費が大幅に減っていることがわかります。
## キーボード入力
外部デバイスからの割り込みを扱うことができるようになったので、ついにキーボード入力へのサポートを追加することができます。これにより、我々は初めてカーネルと対話することができるようになります。
<aside class="post_aside">
ここでは USB キーボードではなく、[PS/2] キーボードの扱いかたしか説明しないので気をつけてください。ただ、マザーボードは古いソフトウェアをサポートするために USB キーボードを PS/2 デバイスとしてエミュレートするので、我々のカーネルが USB をサポートするまでは USB キーボードのことは忘れてしまっても大丈夫です。
</aside>
[PS/2]: https://ja.wikipedia.org/wiki/PS/2%E3%82%B3%E3%83%8D%E3%82%AF%E3%82%BF
ハードウェアタイマのように、キーボードコントローラは既にデフォルトで有効になっています。なのでキーを押すと、キーボードコントローラは PIC に割り込みを送り、CPU に転送されます。CPU は IDT の中からハンドラ関数を探しますが、対応するエントリは空です。よってダブルフォルトが発生します。
ではキーボード割り込みへのハンドラ関数を追加しましょう。異なる割り込み番号を使うだけで、タイマ割り込み用のハンドラを定義した方法とほとんど同じです:
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
Keyboard, // new
}
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
// new
idt[InterruptIndex::Keyboard.as_usize()]
.set_handler_fn(keyboard_interrupt_handler);
idt
};
}
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!("k");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
[上述](#8259-pic)した図で見たように、キーボードはプライマリ PIC の1番目の線を使います。これはキーボード割り込みは33番(1 + オフセットの32)の割り込みとして CPU に届くということです。このインデックスを `Keyboard` というヴァリアントとして新たに `InterruptIndex` enum に追加します。enum ヴァリアントの値はデフォルトでは前の値に1を足したもの、すなわち33になるので、値を明示的に指定する必要はありません。割り込みハンドラでは、`k` の文字を表示して割り込みコントローラに EOI 信号を送ります。
[上述](#8259-pic)した図で見たように、キーボードはプライマリ PIC の1番目の線を使います。これはキーボード割り込みは33番 (1 + オフセットの32) の割り込みとして CPU に届くということです。このインデックスを新たな `Keyboard` のヴァリアントとして `InterruptIndex` enum に追加します。enum ヴァリアントの値はデフォルトでは前の値に1を足したもの、すなわち33になるので、値を明示的に指定する必要はありません。割り込みハンドラでは、`k` の文字を表示して割り込みコントローラに EOI 信号を送ります。
これでキーを押したときに画面上に `k` の文字が表示されます。しかしこれは最初のキー入力に対してしか動作しません。キーを押し続けたとしても、それ以上 `k` の文字が画面上に表示されることはありません。この理由は、我々が押されたキーの _スキャンコード_ と呼ばれる値を読み取らない限りは、キーボードコントローラは別の割り込みを送らないためです。
### スキャンコードの読み取り
_どの_ キーが押されたか知るためにはキーボードコントローラに問い合わせる必要があります。これは [I/O ポート][I/O port]の `0x60` に割り当てられた PS/2 コントローラのデータポートを読み取ることで行います:
[I/O port]: @/edition-2/posts/04-testing/index.md#i-o-ports
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
print!("{}", scancode);
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
キーボードのデータポートから1バイトのデータを読み取るため、`x86_64` クレートに含まれる [`Port`] 型を使います。この1バイトは[スキャンコード][_scancode_]と呼ばれ、キーのプレス/リリースの状態を表します。今のところはスキャンコードを画面に表示する以外にはなにもしません:
[`Port`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/port/struct.Port.html
[_scancode_]: https://ja.wikipedia.org/wiki/%E3%82%B9%E3%82%AD%E3%83%A3%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%89
![QEMU printing scancodes to the screen when keys are pressed](qemu-printing-scancodes.gif)
上の画像は、私がゆっくりと "123" とタイプしたときの様子です。隣り合うキーは隣り合うスキャンコードを持ち、キーを押したときと離したときのスキャンコードは異なることがわかります。ではスキャンコードを実際のキー操作に正確に変換するためにはどうしたらいいのでしょうか。
### スキャンコードの解釈
_スキャンコードセット_ と呼ばれるスキャンコードとキーのマッピングの標準は3つあります。3つのどれもが、 [IBM XT]、[IBM 3270 PC]、[IBM AT] という、初期の IBM コンピュータのキーボードにさかのぼります。幸運なことに、その後のコンピュータは新たなスキャンコードセットを定義するという流れには乗らず、既存のセットをエミュレートして拡張しました。現在では、多くのキーボードは3つのセットのどれでもエミュレートするよう設定できるようになっています。
[IBM XT]: https://ja.wikipedia.org/wiki/IBM_PC_XT
[IBM 3270 PC]: https://en.wikipedia.org/wiki/IBM_3270_PC
[IBM AT]: https://ja.wikipedia.org/wiki/PC/AT
デフォルトでは、PS/2 キーボードはスキャンコードセット1 ("XT") をエミュレートします。このセットでは、スキャンコードの下位の7ビットでキーを表し、最上位の1ビットで押したか ("0") 離したか ("1") を表します。例えばエンターキーのような元の IBM XT のキーボードに存在しないキーに対しては、エスケープである `0xe0` とそのキーを表すバイトという連続した2つのスキャンコードを生成します。スキャンコードセット1の全てのスキャンコードと対応するキーについては [OSDev Wiki][scancode set 1] を確認してください。
[scancode set 1]: https://wiki.osdev.org/Keyboard#Scan_Code_Set_1
スキャンコードをキーに変換するために `match` 文を使います:
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
// new
let key = match scancode {
0x02 => Some('1'),
0x03 => Some('2'),
0x04 => Some('3'),
0x05 => Some('4'),
0x06 => Some('5'),
0x07 => Some('6'),
0x08 => Some('7'),
0x09 => Some('8'),
0x0a => Some('9'),
0x0b => Some('0'),
_ => None,
};
if let Some(key) = key {
print!("{}", key);
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
上記のコードは、0から9の数字キーが押された場合に変換を行い、それ以外のキーは無視します。全てのスキャンコードに対し、[match] 文を使って対応する文字か `None` を割り当てます。そのあと [`if let`] 構文を使ってオプション型の `key` から値を取り出します。パターン部分に `key` という同じ変数名を使うことでそれ以前の宣言を[シャドーイング][shadow]します。これは Rust において `Option` 型から値を取り出すときによく使うパターンです。
[match]: https://doc.rust-lang.org/book/ch06-02-match.html
[`if let`]: https://doc.rust-lang.org/book/ch18-01-all-the-places-for-patterns.html#conditional-if-let-expressions
[shadow]: https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing
これで数字が表示できるようになりました:
![QEMU printing numbers to the screen](qemu-printing-numbers.gif)
他の文字も同じように変換することができます。幸運なことに、スキャンコードセットの1と2のスキャンコードを変換するための [`pc-keyboard`] というクレートがありますので、これを自分で実装する必要はありません。このクレートを使うために `Cargo.toml` に以下を追加し、`lib.rs` でインポートしましょう:
[`pc-keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/
```toml
# in Cargo.toml
[dependencies]
pc-keyboard = "0.5.0"
```
これでこのクレートを使って `keyboard_interrupt_handler` を書き直すことができます:
```rust
// in/src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
use x86_64::instructions::port::Port;
lazy_static! {
static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(layouts::Us104Key, ScancodeSet1,
HandleControl::Ignore)
);
}
let mut keyboard = KEYBOARD.lock();
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => print!("{}", character),
DecodedKey::RawKey(key) => print!("{:?}", key),
}
}
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
ミューテックスで保護された静的な [`Keyboard`] オブジェクトを作るために `lazy_static` マクロを使います。`Keyboard` は、レイアウトを US キーボードに、スキャンコードセットは1として初期化を行います。[`HandleControl`] パラメタは、`ctrl+[a-z]` を Unicode 文字の `U+0001` から `U+001A` にマッピングさせることができます。この機能は使いたくないので、`Ignore` オプションを使い `ctrl` キーを通常のキーと同様に扱います。
[`HandleControl`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/enum.HandleControl.html
各割り込みでは、ミューテックスをロックし、キーボードコントローラからスキャンコードを読み取り、それを読み取ったスキャンコードを `Option<KeyEvent>` に変換する [`add_byte`] メソッドに渡します。[`KeyEvent`] は、そのイベントを起こしたキーと、それが押されたのか離されたのかの情報を含んでいます。
[`Keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html
[`add_byte`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.add_byte
[`KeyEvent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.KeyEvent.html
このキーイベントを解釈するために、変換可能であればキーイベントを文字に変換する [`process_keyevent`] メソッドにキーイベントを渡します。例えば `A` キーの押下イベントを、シフトキーが押されていたかによって小文字の `a` か大文字の `A` に変換します。
[`process_keyevent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.process_keyevent
修正した割り込みハンドラで、テキストが入力できるようになります:
![Typing "Hello World" in QEMU](qemu-typing.gif)
### キーボードの設定
例えば使用するスキャンコードセットを変えるなど、PS/2 キーボードの一部の設定を変えることができます。もうこの記事は長くなりすぎたのでそれについて説明することはしませんが、OSDev Wiki に[設定コマンド][configuration commands]の概要が記載されています。
[configuration commands]: https://wiki.osdev.org/PS/2_Keyboard#Commands
## まとめ
この記事では、外部割り込みを有効にする方法とそれを処理する方法について説明しました。8259 PIC とそのプライマリ/セカンダリレイアウト、割り込み番号をマッピングし直す方法、そして "end of interrupt" 信号について学びました。我々はハードウェアタイマとキーボード向けの割り込みハンドラを実装し、次の割り込みまで CPU を停止させる `hlt` 命令について学びました。
これで我々はカーネルと対話することができるようになり、小さなシェルやシンプルなゲームを作るための基本的な構成要素を得ることができました。
## 次は?
タイマ割り込みは、定期的に動作中のプロセスに割り込み、制御をカーネルに戻す方法を提供するという意味で OS にとって必要不可欠なものです。この上でカーネルは別のプロセスに実行を切り替えることで、まるで複数のプロセスが並列に動いているように見せているのです。
ただし、プロセスやスレッドを作る前に、それらにメモリを割り当てる方法が必要です。次の記事では、メモリ管理という基本的な構成要素を提供するため、メモリ管理について調査していきます。

View File

@@ -1,740 +0,0 @@
+++
title = "하드웨어 인터럽트"
weight = 7
path = "ko/hardware-interrupts"
date = 2018-10-22
[extra]
chapter = "Interrupts"
# Please update this when updating the translation
translation_based_on_commit = "a108367d712ef97c28e8e4c1a22da4697ba6e6cd"
# GitHub usernames of the people that translated this post
translators = ["JOE1994"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["dalinaum"]
+++
이 글에서는 프로그래밍 할 수 있는 인터럽트 컨트롤러가 인터럽트들을 CPU로 정확히 전달하도록 설정할 것입니다. 새로운 인터럽트들을 처리하기 위해 인터럽트 서술자 테이블 (interrupt descriptor table)에 새로운 엔트리들을 추가할 것입니다 (이전에 예외 처리 함수를 등록했듯이). 또한 일정 주기마다 타이머 인터럽트를 일으키는 방법 및 키보드 입력 받는 방법도 알아볼 것입니다.
<!-- more -->
이 블로그는 [GitHub 저장소][GitHub]에서 오픈 소스로 개발되고 있으니, 문제나 문의사항이 있다면 저장소의 'Issue' 기능을 이용해 제보해주세요. [페이지 맨 아래][at the bottom]에 댓글을 남기실 수도 있습니다. 이 포스트와 관련된 모든 소스 코드는 저장소의 [`post-07 브랜치`][post branch]에서 확인하실 수 있습니다.
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-07
<!-- toc -->
## 개요
CPU에 연결된 주변 장치들은 인터럽트를 통해 CPU에 알림을 보낼 수 있습니다. 그래서 커널이 주기적으로 키보드 입력이 들어왔는지 확인하게 하는 대신 (이를 [_폴링(polling)_][_polling_] 방식이라고 합니다), 키보드 입력이 들어올 때마다 키보드가 직접 커널에 알림을 보낼 수 있습니다. 이 방식을 사용하면 이벤트 발생 시에만 커널이 행동을 취하면 되므로 에너지 효율성이 더 좋습니다. 또한 이벤트가 발생 시 커널이 다음 poll까지 기다리지 않고 바로 반응할 수 있기에 이벤트에 대한 반응 속도도 더 빠릅니다.
[_polling_]: https://en.wikipedia.org/wiki/Polling_(computer_science)
하드웨어 장치들을 모두 CPU에 직접 연결하는 것은 불가능합니다. 대신 별도의 _인터럽트 컨트롤러 (interrupt controller)_ 가 주변 장치로부터 전송된 인터럽트들을 수합한 뒤 CPU에 알림을 보냅니다.
```
____________ _____
Timer ------------> | | | |
Keyboard ---------> | Interrupt |---------> | CPU |
Other Hardware ---> | Controller | |_____|
Etc. -------------> |____________|
```
대부분의 인터럽트 컨트롤러들은 프로그래밍을 통해 인터럽트마다 다른 우선순위 레벨을 배정하는 것이 가능합니다. 예를 들어, 키보드 인터럽트보다 타이머 인터럽트에 더 높은 우선순위 레벨을 배정하여 CPU에서 시간을 더 정확히 측정할 수 있습니다.
예외와 달리 하드웨어 인터럽트는 _비동기적으로 (asynchronously)_ 일어납니다. 즉 CPU에서 실행 중인 코드와 별개로 인터럽트는 언제든 발생할 수 있다는 것입니다. 따라서, 커널에 인터럽트를 도입하면서 동시성(concurrency)의 형태가 등장하고 동시성 관련 버그 발생의 가능성도 생깁니다. Rust의 엄격한 소유권 (ownership) 모델이 전역 가변 변수 사용을 금지해 동시성 관련 버그 발생 가능성을 줄여주지만, 교착 상태(deadlock)를 막아주지는 못하며 이는 본문 아래에서 곧 확인하실 수 있습니다.
## 8259 PIC
[Intel 8259] 는 프로그래밍 가능한 인터럽트 컨트롤러 (PIC; Programmable Interrupt Controller)이며, 1976년에 처음 도입되었습니다. 이 장치는 오래전에 신형 장치 [APIC]로 대체됐지만, 이전 버전과의 호환성 유지를 위해 그 인터페이스만은 최신 시스템들도 지원하고 있습니다. 8259 PIC를 다루는 것이 APIC를 다루는 것보다 쉽습니다. 그렇기에 인터럽트에 대해 배우고 입문하는 현재 단계에서는 8259 PIC를 쓰고, 이 블로그 시리즈의 이후 글에서는 APIC로 교체하여 사용하겠습니다.
[APIC]: https://en.wikipedia.org/wiki/Intel_APIC_Architecture
Intel 8259 PIC는 8개의 인터럽트 통신선과 CPU와 통신하기 위한 몇 개의 통신선을 가집니다. 과거의 전형적인 PC 시스템은 8259 PIC를 2개 장착하고 있었는데 (주 PIC와 부 PIC), 주 PIC의 인터럽트 통신선 중 하나를 부 PIC에 연결했습니다.
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259
```
____________ ____________
Real Time Clock --> | | Timer -------------> | |
ACPI -------------> | | Keyboard-----------> | | _____
Available --------> | Secondary |----------------------> | Primary | | |
Available --------> | Interrupt | Serial Port 2 -----> | Interrupt |---> | CPU |
Mouse ------------> | Controller | Serial Port 1 -----> | Controller | |_____|
Co-Processor -----> | | Parallel Port 2/3 -> | |
Primary ATA ------> | | Floppy disk -------> | |
Secondary ATA ----> |____________| Parallel Port 1----> |____________|
```
위 도표는 인터럽트 통신선을 배정하는 전형적인 방식을 보여줍니다. 15개의 선 중 대부분은 어떤 장치와 연결할지 이미 정해져 있습니다. 예를 들어, 부 PIC의 4번 통신선은 마우스에 연결됩니다.
각 컨트롤러는 "command" 포트와 "data" 포트, 이 2개의 [입출력 포트][I/O ports]들을 사용해 설정합니다. 주 PIC는 `0x20`번 포트가 command 포트, `0x21`번 포트가 data 포트입니다. 부 PIC는 `0xa0`번 포트가 command 포트, `0xa1` 포트가 data 포트입니다. PIC를 설정하는 자세한 방법에 대해서는 [osdev.org의 글][article on osdev.org]을 찾아보시길 바랍니다.
[I/O ports]: @/edition-2/posts/04-testing/index.md#i-o-ports
[article on osdev.org]: https://wiki.osdev.org/8259_PIC
### 구현
위 PIC들의 기본 설정에서 PIC는 0-15 구간의 인터럽트 벡터 번호를 CPU에 전송합니다. IDT에서 이 구간의 인터럽트 벡터 번호들은 이미 CPU 예외들에 배정되어 있기에, PIC의 기본 설정을 그대로 사용하지 못합니다. 예를 들면 벡터 번호 8은 더블 폴트에 배정되어 있습니다. 벡터 번호가 중복되는 문제를 해결하려면 PIC가 전송하는 인터럽트들을 다른 벡터 번호에 재배정 해야 합니다. 기존 예외들의 벡터 번호와 겹치지 않는 이상 인터럽트들에 어떤 번호를 배정하는지는 크게 중요하지 않습니다만, 예외들에 배정된 첫 32개의 슬롯 다음 비는 32-47 구간의 벡터 번호를 고르는 것이 일반적입니다.
PIC 장치의 command 포트 및 data 포트에 특수한 값을 쓰면 장치 설정을 변경할 수 있습니다. 운 좋게도 [`pic8259`] 크레이트 덕에 우리가 장치 설정 초기화/변경 로직을 직접 작성할 필요는 없습니다. 작동 원리가 궁금하시다면 해당 크레이트의 [소스 코드][pic crate source]를 직접 확인해보세요. 코드양이 많지 않고 문서화도 잘 되어 있습니다.
[pic crate source]: https://docs.rs/crate/pic8259/0.10.1/source/src/lib.rs
의존 크레이트로 해당 크레이트를 추가하기 위해 아래의 코드를 추가합니다.
[`pic8259`]: https://docs.rs/pic8259/0.10.1/pic8259/
```toml
# in Cargo.toml
[dependencies]
pic8259 = "0.10.1"
```
이 크레이트의 [`ChainedPics`] 구조체는 위에서 봤던 주/부 PIC 연결 방식을 적절한 추상 레벨에서 표현합니다. 이 구조체는 아래처럼 사용하도록 설계되었습니다.
[`ChainedPics`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html
```rust
// in src/interrupts.rs
use pic8259::ChainedPics;
use spin;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
```
위에서 언급했듯이 PIC들이 사용할 벡터 번호의 오프셋을 32-47 구간에서 선택합니다. `ChainedPics` 구조체를 감싼 `Mutex``lock` 함수를 통해 안전하게 값을 수정할 수 있는데, 이는 다음 단계에서 유용합니다. `ChainedPics::new` 함수에 잘못된 오프셋을 넘기면 undefined behavior가 일어날 수 있어 이 함수는 unsafe 함수로 정의되었습니다.
[spin mutex lock]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html#method.lock
이제 `init` 함수에서 8259 PIC 장치를 초기화할 수 있습니다.
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() }; // 새로 추가함
}
```
[`initialize`] 함수를 사용해 PIC 장치를 초기화합니다. PIC 장치를 잘못 초기화하면 undefined behavior를 일으킬 수 있으므로, `ChainedPics::new` 함수와 마찬가지로 이 함수도 unsafe 함수로 정의되었습니다.
[`initialize`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html#method.initialize
추가한 코드에 문제가 없었다면, 다시 `cargo run`을 실행해도 예전처럼 "It did not crash"라는 메시지가 출력될 것입니다.
## 인터럽트 활성화하기
CPU 설정에서 인터럽트 사용이 해제되어 있었기에 아직 아무 일도 일어나지 않았습니다. 인터럽트 사용이 해제되어 있으면 CPU는 인터럽트 컨트롤러부터 오는 신호를 전혀 받지 않고, 따라서 어떤 인터럽트도 CPU에 도달할 수 없습니다. CPU 설정을 바꿔보겠습니다.
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() };
x86_64::instructions::interrupts::enable(); // 새로 추가함
}
```
`x86_64` 크레이트의 `interrupts::enable` 함수는 `sti` 명령어 (“set interrupts”)를 실행해 외부 인터럽트를 사용하도록 설정합니다. 이제 `cargo run`을 실행하면 더블 폴트가 발생하는 것을 확인할 수 있습니다.
![QEMU printing `EXCEPTION: DOUBLE FAULT` because of hardware timer](qemu-hardware-timer-double-fault.png)
더블 폴트가 발생하는 이유는, [Intel 8253] 장치에서 기본적으로 하드웨어 타이머를 사용하도록 설정이 되어 있고, CPU에서 인터럽트 사용을 활성화한 직후부터 타이머 인터럽트가 CPU로 전송되기 때문입니다. 우리가 아직 타이머 인터럽트 처리 함수를 정의하지 않았기 때문에 더블 폴트 처리 함수가 호출됩니다.
[Intel 8253]: https://en.wikipedia.org/wiki/Intel_8253
## 타이머 인터럽트 처리하기
[위 도표](#8259-pic)를 보면 타이머는 주 PIC의 0번 통신선을 사용합니다. 이는 즉 타이머 인터럽트가 CPU에 인터럽트 벡터 번호가 32 (0 + 오프셋 32)인 인터럽트로 전송된다는 것을 뜻합니다. 코드에 번호 32를 그대로 적지 않고 `InterruptIndex` enum에 저장합니다.
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
```
[C언어의 enum][C-like enum]처럼 이 enum은 각 분류에 사용할 인덱스 값을 지정할 수 있습니다. `repr(u8)` 속성은 해당 enum을 `u8` 타입으로서 저장 및 표현되도록 합니다. 향후에 새로운 인터럽트들을 지원해야 할 때 이 enum에 새로운 분류를 추가할 것입니다.
[C-like enum]: https://doc.rust-lang.org/reference/items/enumerations.html#custom-discriminant-values-for-fieldless-enumerations
이제 타이머 인터럽트를 처리할 함수를 작성할 수 있습니다.
```rust
// in src/interrupts.rs
use crate::print;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
idt[InterruptIndex::Timer.as_usize()]
.set_handler_fn(timer_interrupt_handler); // 새로 추가함
idt
};
}
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
}
```
`timer_interrupt_handler` 함수는 우리가 가진 다른 예외 처리 함수들과 같은 함수 원형을 가지는데, 그 이유는 CPU가 예외와 인터럽트에 같은 방식으로 대응하기 때문입니다 (유일한 차이점은 일부 예외들이 오류 코드를 추가로 push한다는 것). [`InterruptDescriptorTable`] 구조체는 [`IndexMut`] 트레이트를 구현해서 배열을 색인하는 것과 동일한 문법을 써서 테이블의 각 엔트리에 접근할 수 있습니다.
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[`IndexMut`]: https://doc.rust-lang.org/core/ops/trait.IndexMut.html
우리가 작성한 타이머 인트럽트 처리 함수는 화면에 점을 출력합니다. 타이머 인터럽트는 주기적으로 발생하므로, 타이머 주기마다 화면에 새로운 점이 출력되기를 기대하는 것이 자연스럽습니다. 하지만 커널을 실행해 보면 화면에 점이 단 1개만 출력될 것입니다.
![QEMU printing only a single dot for hardware timer](qemu-single-dot-printed.png)
### End of Interrupt
점이 1개만 출력되는 이유는 PIC가 인터럽트 처리 함수로부터 명시적으로 “end of interrupt” (EOI) 신호가 전송되어 오기를 기다리기 때문입니다. 이 신호는 PIC에 해당 인터럽트가 처리되었으며 시스템이 다음 인터럽트를 받을 준비가 된 것을 알립니다. 신호를 받지 못한 PIC는 시스템이 아직 첫 타이머 인터럽트를 처리 중이라 생각하고 EOI 신호가 올 때까지 다음 인터럽트를 보내지 않고 기다리는 것입니다.
static으로 선언된 `PICS` 구조체를 다시 사용해 EOI 신호를 보냅니다.
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
```
`notify_end_of_interrupt` 함수는 주 PIC와 부 PIC 중 누가 인터럽트를 보냈었는지 파악하고, 그 후 `command` 포트와 `data` 포트를 사용해 인터럽트를 전송했던 PIC로 EOI 신호를 보냅니다. 부 PIC가 인터럽트를 보냈었다면, 부 PIC가 주 PIC의 입력 통신선에 연결되어 있다 보니 두 PIC 모두 EOI 신호를 받게 됩니다.
여기서 우리는 올바른 인터럽트 벡터 번호를 사용하도록 주의해야 합니다. 잘못된 번호를 쓰면, 아직 CPU로 전송하지 않은 중요한 인터럽트가 소실되거나 시스템이 아무 반응도 하지 않게 될 수 있습니다. 이런 이유로 `notify_end_of_interrupt` 함수가 `unsafe`로 선언된 것입니다.
다시 `cargo run`을 실행하면 화면에 주기적으로 점이 찍히는 것을 확인할 수 있습니다.
![QEMU printing consecutive dots showing the hardware timer](qemu-hardware-timer-dots.gif)
### 타이머 설정하기
우리가 쓰는 하드웨어 타이머는 _Programmable Interval Timer_, 또는 줄여서 PIT라고 부릅니다. 이름에서 알 수 있듯이, 프로그래밍을 통해 인터럽트 사이 시간 간격을 조정할 수 있습니다. 곧 [APIC 타이머][APIC timer]로 교체해 사용할 것이기 때문에 PIT에 대해 자세히 다루지는 않겠습니다만, OSDev 위키에 [PIT를 설정하는 방법][configuring the PIT]에 대한 자세한 글이 있으니 참고하시기 바랍니다.
[APIC timer]: https://wiki.osdev.org/APIC_timer
[configuring the PIT]: https://wiki.osdev.org/Programmable_Interval_Timer
## 교착 상태 (Deadlock)
이제 우리의 커널에 동시성의 개념이 등장했습니다. 타이머 인터럽트는 비동기적으로 발생하기에 `_start` 함수 실행 중 언제든 발생할 수 있습니다. Rust의 소유권 (ownership) 시스템이 다양한 동시성 관련 버그를 컴파일 시간에 방지하지만, 교착 상태는 막지 못합니다. 스레드(thread)가 해제되지 않을 lock을 얻으려고 할 때 교착 상태가 일어나며, 해당 스레드는 영원히 대기 상태에 갇히게 됩니다.
현재 우리의 커널에서 교착 상태를 일으킬 수 있습니다. 우리가 쓰는 `println` 매크로가 호출하는 `vga_buffer::_print` 함수는 스핀 락(spinlock)을 통해 [전역 변수 `WRITER`에 대한 lock을 잠급니다][vga spinlock].
[vga spinlock]: @/edition-2/posts/03-vga-text-buffer/index.md#spinlocks
```rust
// in src/vga_buffer.rs
[]
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
위 함수는 `WRITER`에 대한 lock을 잠그고 `write_fmt`를 호출하며, 위 함수의 반환 직전에 `WRITER`에 대한 lock을 암묵적으로 해제합니다. `WRITER`에 대한 lock이 잠긴 상태에서 인터럽트가 발생하고, 해당 인터럽트의 처리 함수가 화면에 뭔가 출력하려 한다고 가정해봅시다.
| 시간 순서 | _start | 인터럽트 처리 함수 |
| --------- | ------------------------- | ----------------------------------------------- |
| 0 | `println!` 호출 | &nbsp; |
| 1 | `print``WRITER`를 잠금 | &nbsp; |
| 2 | | **인터럽트 발생**, 인터럽트 처리 함수 실행 시작 |
| 3 | | `println!` 호출 |
| 4 | | `print`가 이미 잠긴 `WRITER`를 또 잠그려고 함 |
| 5 | | `print`가 이미 잠긴 `WRITER`를 또 잠그려고 함 |
| … | | … |
| _never_ | _`WRITER` 잠금 해제_ |
`WRITER`에 대한 lock이 잠겨 있으니, 인터럽트 처리 함수는 해당 lock이 풀릴 때까지 기다립니다. 하지만 `_start` 함수는 인터럽트 처리 함수가 반환한 후에 실행을 재개하기 때문에 lock이 풀리지 않습니다. 그 결과, 시스템 전체가 응답 불가 상태가 됩니다.
### 교착 상태 일으키기
`_start` 함수의 맨 마지막 loop 안에서 화면에 출력을 시도하면 쉽게 커널에 교착 상태를 일으킬 수 있습니다.
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
loop {
use blog_os::print;
print!("-"); // 새로 추가함
}
}
```
QEMU에서 실행하면 아래와 같은 출력을 얻게 됩니다.
![QEMU output with many rows of hyphens and no dots](./qemu-deadlock.png)
첫 타이머 인터럽트 발생 전까지는 제한된 수의 붙임표(`-`)가 출력됩니다. 첫 타이머 인터럽트 후, 타이머 인터럽트 처리 함수가 온점(`.`)을 출력하려다 교착 상태에 빠지고 시스템은 아무 반응을 하지 않습니다. 이것이 출력 내용에 온점이 전혀 없는 이유입니다.
타이머 인터럽트가 비동기적으로 발생하다보니 커널을 실행할 때마다 출력되는 붙임표의 수가 다를 수 있습니다. 동시성 관련 버그들은 실행 결과가 이렇게 비결정론적(non-deterministic)인 경우가 많아 디버깅하기 쉽지 않습니다.
### 교착 상태 방지하기
`Mutex`가 잠긴 동안 인터럽트를 해제하면 교착 상태를 방지할 수 있습니다.
```rust
// in src/vga_buffer.rs
/// Prints the given formatted string to the VGA text buffer
/// through the global `WRITER` instance.
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // 새로 추가함
interrupts::without_interrupts(|| { // 새로 추가함
WRITER.lock().write_fmt(args).unwrap();
});
}
```
[`without_interrupts`] 함수는 인자로 받은 [클로저(closure)][closure]를 인터럽트가 없는 환경에서 실행합니다. 이 함수를 통해 `Mutex`가 잠긴 동안 인터럽트가 발생하지 않게 보장합니다. 커널을 다시 실행하면 커널이 응답 불가 상태에 빠지지 않고 계속 실행되는 것을 확인할 수 있습니다. (화면 스크롤이 너무 빠르게 내려가다 보니 화면에 점이 출력되는 것을 확인하기 어려울 수 있습니다. `_start` 함수의 loop 안에 `for _ in 0..10000 {}`를 삽입하는 등의 방법으로 출력 속도를 늦춰 보세요.)
[`without_interrupts`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/interrupts/fn.without_interrupts.html
[closure]: https://doc.rust-lang.org/book/ch13-01-closures.html
직렬 포트를 이용한 출력 함수 코드 역시 같은 방식으로 수정하여 교착 상태를 방지합니다.
```rust
// in src/serial.rs
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // 새로 추가함
interrupts::without_interrupts(|| { // 새로 추가함
SERIAL1
.lock()
.write_fmt(args)
.expect("Printing to serial failed");
});
}
```
인터럽트를 해제하는 것이 일반적으로 사용할 해결 방식이 아니라는 것을 아셔야 합니다. 인터럽트를 해제하면 인터럽트가 최대로 많이 몰렸을 때 시스템이 인터럽트에 반응할 수 있는 시간 (worst-case interrupt latency)이 늦어집니다. 따라서 인터럽트를 해제하려면 아주 짧은 시간 동안만 해야 합니다.
## 경쟁 상태 (Race Condition) 예방하기
`cargo test`를 실행하면 테스트 `test_println_output`가 때때로 실패하는 것을 확인할 수 있습니다:
```
> cargo test --lib
[…]
Running 4 tests
test_breakpoint_exception...[ok]
test_println... [ok]
test_println_many... [ok]
test_println_output... [failed]
Error: panicked at 'assertion failed: `(left == right)`
left: `'.'`,
right: `'S'`', src/vga_buffer.rs:205:9
```
이 테스트가 때때로 실패하는 것은 이 테스트와 우리가 작성한 타이머 처리 함수 간 _경쟁 상태 (race condition)_ 때문입니다. 예전에 작성했던 이 테스트의 코드를 다시 살펴보겠습니다.
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
let s = "Some test string that fits on a single line";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}
```
이 테스트는 VGA 버퍼에 문자열에 출력한 후 `buffer_chars` 배열을 직접 순회하여 출력된 내용을 확인합니다. 경쟁 상태가 생기는 이유는, `println``screen_char`를 읽는 코드 사이에 타이머 인터럽트 처리 함수가 호출될 수 있기 때문입니다. 이는 컴파일 시간에 Rust가 막아주는 위험한 _데이터 레이스 (data race)_ 와는 다릅니다. 자세한 내용은 [_Rustonomicon_][nomicon-races]을 참고해주세요.
[nomicon-races]: https://doc.rust-lang.org/nomicon/races.html
이 문제를 고치려면 위 테스트가 실행 중에 `WRITER`에 대한 lock을 계속 잠그고 있어야 합니다. 그렇게 해야 타이머 처리 함수가 그 사이에 `.`을 출력하지 못합니다. 아래와 같이 테스트를 수정합니다.
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
use core::fmt::Write;
use x86_64::instructions::interrupts;
let s = "Some test string that fits on a single line";
interrupts::without_interrupts(|| {
let mut writer = WRITER.lock();
writeln!(writer, "\n{}", s).expect("writeln failed");
for (i, c) in s.chars().enumerate() {
let screen_char = writer.buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
});
}
```
변경 사항들을 정리하면 아래와 같습니다.
- 테스트 실행 중에는 `lock()` 함수를 사용해 WRITER를 잠가 놓습니다. `println` 대신 [`writeln`] 매크로를 써서 이미 잠긴 WRITER를 이용해 메시지를 출력합니다.
- 또 다른 교착 상태를 피하려고 테스트 중에는 인터럽트의 사용을 해제합니다. 그렇게 하지 않으면 테스트 실행 중 WRITER가 잠긴 상태에서 발생한 다른 인터럽트가 테스트 실행을 방해할 수 있습니다.
- 테스트 실행 시작 전에 타이머 인터럽트 처리 함수가 실행될 수 있으니 문자열 `s` 출력 전에 개행 문자 `\n`을 출력합니다. 이렇게 하면 타이머 인터럽트 처리 함수가 현재 행에 이미 `.` 문자를 여럿 출력했더라도 이 테스트가 실패하지 않을 것입니다.
[`writeln`]: https://doc.rust-lang.org/core/macro.writeln.html
이제 다시 `cargo test`를 실행하면 항상 성공하는 것을 확인하실 수 있습니다.
위에서 다룬 경쟁 상태 (race condition)는 테스트 실패를 일으키는 것 외에 큰 해를 끼치지는 않았습니다. 하지만 비결정론적인 결과를 낳는다는 본질적인 특성 때문에 이보다 디버깅하기 더 까다로운 경쟁 상태 역시 존재할 수 있습니다. 데이터 레이스(data race)라는 가장 위험한 종류의 경쟁 상태는 시스템 크래시나 메모리 커럽션 (memory corruption) 등 온갖 undefined behavior를 일으킬 수 있지만, 다행히 Rust가 우리를 데이터 레이스로부터 지켜줍니다.
## `hlt` 명령어
여태까지는 `_start``panic` 함수들의 맨 마지막에 간단한 빈 loop를 사용했습니다. 이 loop 때문에 CPU는 실행을 종료하지 않는데, CPU가 딱히 할 일이 없는데 CPU를 최고 속도로 가동하는 것은 에너지 효율성 측면에서 매우 비효율적입니다. 커널 실행 후 태스크 매니저를 보시면 QEMU 프로세스가 항상 CPU 시간을 100% 가까이 사용하고 있을 것입니다.
우리가 정말 해야 할 일은 다음 인터럽트 전까지 CPU가 정지하도록 하는 것입니다. CPU는 저전력 상태의 대기 모드에서 실행을 정지하고 대기할 수 있습니다. `hlt` 명령어를 쓰면 CPU가 저전력 대기 상태에 들어가게 할 수 있습니다. 이 명령어를 사용해 에너지를 효율적으로 사용하는 무한 루프를 작성합니다.
[`hlt` instruction]: https://en.wikipedia.org/wiki/HLT_(x86_instruction)
```rust
// in src/lib.rs
pub fn hlt_loop() -> ! {
loop {
x86_64::instructions::hlt();
}
}
```
함수 `instructions::hlt`는 그저 hlt 어셈블리 명령어를 [얇게 감싸 포장][thin wrapper]합니다. 이 명령어로는 메모리 안전성을 해칠 방법이 없어 안전합니다.
[thin wrapper]: https://github.com/rust-osdev/x86_64/blob/5e8e218381c5205f5777cb50da3ecac5d7e3b1ab/src/instructions/mod.rs#L16-L22
`_start``panic` 함수에서 사용하던 무한 루프를 방금 작성한 `hlt_loop`로 교체합니다.
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
println!("It did not crash!");
blog_os::hlt_loop(); // 새로 추가함
}
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
blog_os::hlt_loop(); // 새로 추가함
}
```
`lib.rs` 또한 마찬가지로 수정합니다.
```rust
// in src/lib.rs
/// `cargo test`의 실행 시작 지점
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init();
test_main();
hlt_loop(); // 새로 추가함
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
hlt_loop(); // 새로 추가함
}
```
이제 QEMU에서 커널을 실행하면 CPU 사용량이 훨씬 감소한 것을 확인할 수 있습니다.
## 키보드 입력
외부 장치로부터 오는 인터럽트를 커널에서 처리할 수 있게 되었으니, 이제 드디어 커널이 키보드 입력을 지원하도록 만들 차례입니다. 키보드 입력을 지원함으로써 커널과 상호작용할 수 있게 될 것입니다.
<aside class="post_aside">
이 글에서는 [PS/2] 키보드에 대해서만 다루고 USB 키보드는 다루지 않습니다. 하지만 메인보드가 구식 소프트웨어를 지원하기 위해 USB 키보드를 PS/2 장치로 에뮬레이팅하기 때문에, 추후 커널에 정식으로 USB 지원을 추가하기 전까지는 USB 키보드에 대해 고민하지 않아도 됩니다.
</aside>
[PS/2]: https://en.wikipedia.org/wiki/PS/2_port
하드웨어 타이머와 마찬가지로, 키보드 컨트롤러의 인터럽트도 기본적으로 사용이 활성화되어 있습니다. 키보드 키를 누르면 키보드 컨트롤러는 PIC로 인터럽트를 보내고, PIC는 다시 그 인터럽트를 CPU로 전달합니다. CPU는 IDT에서 키보드 인터럽트의 엔트리를 조회하지만, 등록된 인터럽트 처리 함수가 없어 더블 폴트가 발생합니다.
키보드 인터럽트를 처리하는 함수를 추가합니다. 다른 인터럽트 번호를 사용한다는 점을 빼면, 이전에 타이머 인터럽트 처리 함수를 작성했던 것과 매우 유사합니다.
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
Keyboard, // 새로 추가함
}
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
// 새로 추가함
idt[InterruptIndex::Keyboard.as_usize()]
.set_handler_fn(keyboard_interrupt_handler);
idt
};
}
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!("k");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
[위 도표](#8259-pic)를 보면 키보드는 주 PIC의 1번 통신선을 사용합니다. 즉 CPU에 전달된 키보드 인터럽트의 벡터 번호는 33 (1 + 오프셋 32)이 됩니다. 해당 번호를 `InterruptIndex` enum의 새 분류 `Keyboard`에 배정합니다. `Keyboard`의 값을 명시적으로 정해주지 않아도 바로 이전 분류의 값에 1을 더한 값(=33)이 배정됩니다. 인터럽트 처리 함수는 `k`를 출력한 후 인터럽트 컨트롤러에 EOI 신호를 전송합니다.
이제 아무 키를 하나 입력하면 화면에 `k`가 출력됩니다. 하지만 아무 키를 하나 새로 입력하면 화면에 `k`가 새로 출력되지 않습니다. 그 이유는 기존에 입력된 키의 _스캔 코드 (scancode)_ 를 우리가 읽어 가지 않는 한 키보드 컨트롤러가 새 인터럽트를 보내지 않기 때문입니다.
### 스캔 코드 읽기
_어떤_ 키가 입력됐는지 확인하려면 키보드 컨트롤러에 저장된 정보를 확인해야 합니다. PS/2 컨트롤러의 데이터 포트, 즉 `0x60`번 [입출력 포트 (I/O port)][I/O port]를 읽어 들여 어떤 키가 입력됐는지 확인할 수 있습니다.
[I/O port]: @/edition-2/posts/04-testing/index.md#i-o-ports
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
print!("{}", scancode);
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
`x86_64` 크레이트의 [`Port`] 타입을 사용해 키보드의 데이터 포트로부터 1바이트를 읽어옵니다. 이 1바이트의 데이터를 [_스캔 코드 (scancode)_][_scancode_]라고 부르며, 누른 키 또는 누른 상태에서 뗀 키의 정보를 가집니다. 일단은 스캔 코드를 출력하기만 할 뿐, 읽은 스캔 코드 값을 이용한 작업은 하지 않습니다.
[`Port`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/port/struct.Port.html
[_scancode_]: https://en.wikipedia.org/wiki/Scancode
![QEMU printing scancodes to the screen when keys are pressed](qemu-printing-scancodes.gif)
위 이미지는 제가 키보드로 천천히 "123"을 입력했을 때의 화면을 보여줍니다. 이를 통해 인접한 키들은 인접한 값의 스캔 코드를 가진다는 것, 그리고 키를 누를 때와 누른 키를 뗄 때 서로 다른 스캔 코드가 발생한다는 것을 알 수 있습니다. 근데 각 스캔 코드는 실제 키 누름/뗌에 어떤 기준으로 배정된 것일까요?
### 스캔 코드 해석하기
스캔 코드를 키보드 키에 배정하는 표준을 _스캔코드 셋 (scancode set)_ 이라 부르며, 서로 다른 3가지 표준이 존재합니다. 셋 모두 초기의 IBM 컴퓨터들 ([IBM XT], [IBM 3270 PC], [IBM AT])로부터 기원합니다. 이후의 컴퓨터들은 새로운 스캔코드 셋을 정의하는 대신 기존의 것들을 지원하거나 확장해 사용했습니다. 오늘날 대부분의 키보드는 에뮬레이팅을 통해 이 3가지 셋 중 어느 것이든 사용할 수 있습니다.
[IBM XT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer_XT
[IBM 3270 PC]: https://en.wikipedia.org/wiki/IBM_3270_PC
[IBM AT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer/AT
PS/2 키보드는 기본적으로 1번 스캔 코드 셋 ("XT")를 사용하게 되어 있습니다. 이 셋에서 스캔 코드의 하위 7비트는 입력된 키를 표현하고, 최상위 비트는 키를 누른 것인지 ("0") 혹은 키에서 손을 뗀 것인지 ("1") 표현합니다. 엔터 키처럼 [IBM XT] 키보드에 없었던 키들은 2개의 스캔 코드 (`0xe0` 그리고 그 후 키를 나타내는 1바이트)를 연이어 생성합니다. [OSDev Wiki][scancode set 1]를 보시면 1번 스캔코드 셋의 모든 스캔 코드와 그에 대응하는 키를 확인하실 수 있습니다.
[scancode set 1]: https://wiki.osdev.org/Keyboard#Scan_Code_Set_1
`match`문을 사용해 스캔 코드를 알맞는 키에 대응시켜 해석합니다.
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
// 새로 추가함
let key = match scancode {
0x02 => Some('1'),
0x03 => Some('2'),
0x04 => Some('3'),
0x05 => Some('4'),
0x06 => Some('5'),
0x07 => Some('6'),
0x08 => Some('7'),
0x09 => Some('8'),
0x0a => Some('9'),
0x0b => Some('0'),
_ => None,
};
if let Some(key) = key {
print!("{}", key);
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
위 코드는 0-9의 숫자 키 누름을 인식하고 출력하며, 다른 키는 무시합니다. [match]문을 사용해 각 스캔코드에 문자 또는 `None`을 배정합니다. 그 후 [`if let`]을 사용해 스캔 코드에 배정된 문자 `key`를 추출합니다. 이미 존재하던 변수 `key`와 같은 이름을 패턴에서 사용해 기존 변수의 정의를 [shadow]하는데, 이는 Rust에서 `Option` 타입 안의 값을 추출할 때 자주 사용되는 방식입니다.
[match]: https://doc.rust-lang.org/book/ch06-02-match.html
[`if let`]: https://doc.rust-lang.org/book/ch18-01-all-the-places-for-patterns.html#conditional-if-let-expressions
[shadow]: https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing
이제 키보드로 숫자를 입력할 수 있습니다.
![QEMU printing numbers to the screen](qemu-printing-numbers.gif)
나머지 키를 인식하는 것도 위와 마찬가지 방법으로 진행하면 됩니다. 다행히도, [`pc-keyboard`] 크레이트가 1번/2번 스캔코드 셋을 해석하는 기능을 제공합니다. `Cargo.toml`에 이 크레이트를 추가하고 `lib.rs`에서 불러와 사용합니다.
[`pc-keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/
```toml
# in Cargo.toml
[dependencies]
pc-keyboard = "0.5.0"
```
`pc-keyboard` 크레이트를 사용해 `keyboard_interrupt_handler` 함수를 새로 작성합니다.
```rust
// in/src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
use x86_64::instructions::port::Port;
lazy_static! {
static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(layouts::Us104Key, ScancodeSet1,
HandleControl::Ignore)
);
}
let mut keyboard = KEYBOARD.lock();
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => print!("{}", character),
DecodedKey::RawKey(key) => print!("{:?}", key),
}
}
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
`lazy_static` 매크로를 사용해 Mutex로 감싼 [`Keyboard`] 타입의 static 오브젝트를 얻습니다. `Keyboard`가 미국 키보드 레이아웃과 1번 스캔코드 셋을 사용하도록 초기화합니다. [`HandleControl`] 매개변수를 사용하면 `ctrl+[a-z]` 키 입력을 유니코드 `U+0001`에서 `U+001A`까지 값에 대응시킬 수 있습니다. 우리는 그렇게 하지 않기 위해 해당 매개변수에 `Ignore` 옵션을 주고 `ctrl` 키를 일반 키로서 취급합니다.
[`HandleControl`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/enum.HandleControl.html
인터럽트마다 우리는 Mutex를 잠그고 키보드 컨트롤러로부터 스캔 코드를 읽어온 후, 그 스캔 코드를 [`add_byte`] 함수에 전달합니다. 이 함수는 스캔 코드를 `Option<KeyEvent>`으로 변환합니다. [`KeyEvent`] 타입은 입력된 키의 정보와 키의 누름/뗌 정보를 저장합니다.
[`Keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html
[`add_byte`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.add_byte
[`KeyEvent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.KeyEvent.html
[`process_keyevent`] 함수가 인자로 받은 `KeyEvent`를 변환하여 입력된 키의 문자를 반환합니다 (변환 불가한 경우 `None` 반환). 예를 들어, `A`키 입력 시 shift키 입력 여부에 따라 소문자 `a` 또는 대문자 `A`를 얻습니다.
[`process_keyevent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.process_keyevent
수정한 인터럽트 처리 함수를 통해 텍스트를 입력한 대로 화면에 출력할 수 있습니다.
![Typing "Hello World" in QEMU](qemu-typing.gif)
### 키보드 설정하기
PS/2 키보드의 일부 설정을 변경하는 것이 가능한데, 예를 들면 어떤 스캔 코드 집합을 사용할지 지정할 수 있습니다. 본문이 너무 길어지니 해당 내용까지 다루지는 않겠지만, OSDev 위키를 확인하시면 [키보드 설정을 변경할 때 사용할 수 있는 명령어][configuration commands]들의 목록을 보실 수 있습니다.
[configuration commands]: https://wiki.osdev.org/PS/2_Keyboard#Commands
## 정리
이 글에서는 인터럽트를 활성화하고 외부 인터럽트를 처리하는 방법에 대해 설명했습니다. 우리는 8259 PIC 장치, 주 PIC와 부 PIC를 연결하는 방식, 인터럽트 번호를 재배정하는 방법, 그리고 "end of interrupt" 신호 등에 대해 배웠습니다. 우리는 하드웨어 타이머와 키보드의 인터럽트 처리 함수를 구현했고, CPU를 다음 인터럽트까지 멈추는 `hlt` 명령어에 대해 배웠습니다.
이제 커널과 상호작용할 수 있게 되었으니, 간단한 커맨드 쉘이나 게임을 작성할 기본적인 도구를 갖춘 셈입니다.
## 다음 단계는 무엇일까요?
운영체제에서 타이머 인터럽트는 필수적인 존재입니다. 그 이유는 타이머 인터럽트를 사용해 주기적으로 실행 중인 프로세스를 멈추고 커널로 제어 흐름을 가져올 수 있기 때문입니다. 그 후 커널은 다른 프로세스를 실행시킬 수 있고, 여러 프로세스가 동시에 실행 중인 듯한 사용자 경험을 제공할 수 있습니다.
프로세스나 스레드를 만들려면 우선 그들이 사용할 메모리를 할당할 방법이 필요합니다. 다음 몇 글들에서는 메모리 할당 기능을 제공하기 위한 메모리 관리 (memory management)에 대해 알아보겠습니다.

View File

@@ -1,740 +0,0 @@
+++
title = "硬件中断"
weight = 7
path = "zh-CN/hardware-interrupts"
date = 2018-10-22
[extra]
# Please update this when updating the translation
translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"
# GitHub usernames of the people that translated this post
translators = ["liuyuran"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["JiangengDong"]
+++
在本文中我们会对可编程的中断控制器进行设置以将硬件中断转发给CPU而要处理这些中断只需要像处理异常一样在中断描述符表中加入一个新条目即可在这里我们会以获取周期计时器的中断和获取键盘输入为例进行讲解。
<!-- more -->
这个系列的 blog 在[GitHub]上开放开发,如果你有任何问题,请在这里开一个 issue 来讨论。当然你也可以在[底部][at the bottom]留言。你可以在[`post-07`][post branch]找到这篇文章的完整源码。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-07
<!-- toc -->
## 前言
中断是其他硬件对CPU发送通知的一种方式所以除了使用 [_轮询_][_polling_] 进程在内核层面定时检查键盘输入以外,由键盘主动通知内核按键输入的结果也是个可行的方案。相比之下,后者可能还更加有用,此时内核只需要处理接收到的事件即可。这也可以极大降低系统的反应延时,因为内核无需等待下一次轮询周期。
[_polling_]: https://en.wikipedia.org/wiki/Polling_(computer_science)
根据常识将所有硬件直连CPU是不可能的所以需要一个统一的 _中断控制器_ 对所有设备中断进行代理并由它间接通知CPU
```
____________ _____
Timer ------------> | | | |
Keyboard ---------> | Interrupt |---------> | CPU |
Other Hardware ---> | Controller | |_____|
Etc. -------------> |____________|
```
绝大多数中断控制器都是可编程的,也就是说可以自行设定中断的优先级,比如我们可以为计时器中断设定比键盘中断更高的优先级,以保证系统时间的精确性。
和异常不同,硬件中断完全是 _异步的_ 也就是说它们可以在任何时候发生且时序完全独立于正在运行的代码。所以我们的内核里就突然添加了一种异步的逻辑形式并且也引入了所有潜在的与异步逻辑相关的Bug可能性。此时Rust严格的所有权模型此时就开始具备优势因为它从根本上禁止了可变的全局状态。但尽管如此死锁很难完全避免这个问题我们会在文章稍后的部分进行说明。
## The 8259 PIC
[Intel 8259] 是一款于1976年发布的可编程中断控制器PIC事实上它已经被更先进的 [APIC] 替代很久了,但其接口依然出于兼容问题被现有系统所支持。但是 8259 PIC 的设置方式比起APIC实在简单太多了所以我们先以前者为例解说一下基本原理在下一篇文章中再切换为APIC。
[APIC]: https://en.wikipedia.org/wiki/Intel_APIC_Architecture
8529具有8个中断管脚和一个和CPU通信的独立管脚而当年的典型系统一般会安装两片 8259 PIC ,一个作为主芯片,另一个则作为副芯片,就像下面这样:
[Intel 8259]: https://en.wikipedia.org/wiki/Intel_8259
```
____________ ____________
Real Time Clock --> | | Timer -------------> | |
ACPI -------------> | | Keyboard-----------> | | _____
Available --------> | Secondary |----------------------> | Primary | | |
Available --------> | Interrupt | Serial Port 2 -----> | Interrupt |---> | CPU |
Mouse ------------> | Controller | Serial Port 1 -----> | Controller | |_____|
Co-Processor -----> | | Parallel Port 2/3 -> | |
Primary ATA ------> | | Floppy disk -------> | |
Secondary ATA ----> |____________| Parallel Port 1----> |____________|
```
上图展示了中断管脚的典型逻辑定义我们可以看到实际上可定义的管脚共有15个例如副PIC的4号管脚被定义为了鼠标。
每个控制器都可以通过两个 [I/O 端口][I/O ports] 进行配置,一个是“指令”端口,另一个是“数据”端口。对于主控制器,端口地址是 `0x20`(指令)和 `0x21`(数据),而对于副控制器,端口地址是 `0xa0`(指令)和 `0xa1`数据。要查看更多关于PIC配置的细节请参见 [article on osdev.org]。
[I/O ports]: @/edition-2/posts/04-testing/index.md#i-o-ports
[article on osdev.org]: https://wiki.osdev.org/8259_PIC
### 实现
PIC默认的配置其实是无法使用的因为它仅仅是将0-15之间的中断向量编号发送给了CPU然而这些编号已经用在了CPU的异常编号中了比如8号代指 double fault 异常。要修复这个错误我们需要对PIC中断序号进行重映射新的序号只需要避开已被定义的CPU异常即可CPU定义的异常数量有32个所以通常会使用32-47这个区段。
我们需要通过往指令和数据端口写入特定数据才能对配置进行编程,幸运的是已经有了一个名叫 [`pic8259`] 的crate封装了这些东西我们无需自己去处理这些初始化方面的细节。
如果你十分好奇其中的细节,这里是 [它的源码][pic crate source],他的内部逻辑其实十分简洁,而且具备完善的文档。
[pic crate source]: https://docs.rs/crate/pic8259/0.10.1/source/src/lib.rs
我们可以这样将 crate 作为依赖加入工程中:
[`pic8259`]: https://docs.rs/pic8259/0.10.1/pic8259/
```toml
# in Cargo.toml
[dependencies]
pic8259 = "0.10.1"
```
这个 crate 提供的主要抽象结构就是 [`ChainedPics`]用于映射上文所说的主副PIC的映射布局它可以这样使用
[`ChainedPics`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html
```rust
// in src/interrupts.rs
use pic8259::ChainedPics;
use spin;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
```
我们成功将PIC的中断编号范围设定为了3247。我们使用 `Mutex` 容器包裹了 `ChainedPics`,这样就可以通过([`lock` 函数][spin mutex lock])拿到被定义为安全的变量修改权限,我们在下文会用到这个权限。`ChainedPics::new` 处于unsafe块因为错误的偏移量可能会导致一些未定义行为。
[spin mutex lock]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html#method.lock
那么现在,我们就可以在 `init` 函数中初始化 8259 PIC 配置了:
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() }; // new
}
```
我们使用 [`initialize`] 函数进行PIC的初始化。正如 `ChainedPics::new` ,这个函数也是 unsafe 的因为里面的不安全逻辑可能会导致PIC配置失败进而出现一些未定义行为。
[`initialize`]: https://docs.rs/pic8259/0.10.1/pic8259/struct.ChainedPics.html#method.initialize
如果一切顺利,我们在运行 `cargo run` 后应当能看到诸如 "It did not crash" 此类的输出信息。
## 启用中断
不过现在什么都不会发生因为CPU配置里面中断还是禁用状态呢也就是说CPU现在根本不会监听来自中断控制器的信息即任何中断都无法到达CPU。我们来启用它
```rust
// in src/lib.rs
pub fn init() {
gdt::init();
interrupts::init_idt();
unsafe { interrupts::PICS.lock().initialize() };
x86_64::instructions::interrupts::enable(); // new
}
```
`x86_64` crate 中的 `interrupts::enable` 会执行特殊的 `sti` (“set interrupts”) 指令来启用外部中断。当我们试着执行 `cargo run`double fault 异常几乎是立刻就被抛出了:
![QEMU printing `EXCEPTION: DOUBLE FAULT` because of hardware timer](qemu-hardware-timer-double-fault.png)
其原因就是硬件计时器(准确的说,是[Intel 8253]默认是被启用的所以在启用中断控制器之后CPU开始接收到计时器中断信号而我们又并未设定相对应的处理函数所以就抛出了 double fault 异常。
[Intel 8253]: https://en.wikipedia.org/wiki/Intel_8253
## 处理计时器中断
我们已经知道 [计时器组件](#the-8259-pic) 使用了主PIC的0号管脚根据上文中我们定义的序号偏移量32所以计时器对应的中断序号也是32。但是不要将32硬编码进去我们将其存储到枚举类型 `InterruptIndex` 中:
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
```
这是一个 [C语言风格的枚举][C-like enum],我们可以为每个枚举值指定其对应的数值,`repr(u8)` 开关使枚举值对应的数值以 `u8` 格式进行存储,这样未来我们可以在这里加入更多的中断枚举。
[C-like enum]: https://doc.rust-lang.org/reference/items/enumerations.html#custom-discriminant-values-for-fieldless-enumerations
那么开始为计时器中断添加一个处理函数:
```rust
// in src/interrupts.rs
use crate::print;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
idt[InterruptIndex::Timer.as_usize()]
.set_handler_fn(timer_interrupt_handler); // new
idt
};
}
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
}
```
`timer_interrupt_handler` 和错误处理函数具有相同的函数签名这是因为CPU对异常和外部中断的处理方式是相同的除了个别异常会传入错误码以外。[`InterruptDescriptorTable`] 结构实现了 [`IndexMut`] trait所以我们可以通过序号来单独修改某一个条目。
[`InterruptDescriptorTable`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html
[`IndexMut`]: https://doc.rust-lang.org/core/ops/trait.IndexMut.html
在我们刚刚写好的处理函数中,我们会往屏幕上输出一个点,随着计时器中断周期性触发,我们应该能看到每一个计时周期过后屏幕上都会多出一个点。然而事实却并不是如此,我们只能在屏幕上看到一个点:
![QEMU printing only a single dot for hardware timer](qemu-single-dot-printed.png)
### 结束中断
这是因为PIC还在等着我们的处理函数返回 “中断结束” (EOI) 信号。该信号会通知控制器终端已处理系统已准备好接收下一个中断。所以如果始终不发送EOI信号那么PIC就会认为我们还在一直处理第一个计时器中断然后暂停了后续的中断信号发送直到接收到EOI信号。
要发送EOI信号我们可以再使用一下 `PICS`
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!(".");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
```
`notify_end_of_interrupt` 会自行判断中断信号发送的源头主PIC或者副PIC并使用指令和数据端口将信号发送到目标控制器。当然如果是要发送到副PIC那么结果上必然等同于同时发送到两个PIC因为副PIC的输入管脚连在主PIC上面。
请注意,这里的中断编码一定不可以写错,不然可能会导致某个中断信号迟迟得不到回应导致系统整体挂起。这也是该函数被标记为不安全的原因。
现在我们再次运行 `cargo run`,就可以看到屏幕上开始正常输出点号了:
![QEMU printing consecutive dots showing the hardware timer](qemu-hardware-timer-dots.gif)
### 配置计时器
我们所使用的硬件计时器叫做 _可编程周期计时器_ PIT就如同字面上的意思一样其两次中断之间的间隔是可配置的。当然不会在此展开说因为我们很快就会使用 [APIC计时器][APIC timer] 来代替它但是你可以在OSDev wiki中找到一些关于[配置PIT计时器][configuring the PIT]的拓展文章。
[APIC timer]: https://wiki.osdev.org/APIC_timer
[configuring the PIT]: https://wiki.osdev.org/Programmable_Interval_Timer
## 死锁
现在,我们的内核里就出现了一种全新的异步逻辑:计时器中断是异步的,所以它可能会在任何时候中断 `_start` 函数的运行。幸运的是Rust的所有权体系为我们在编译期避免了相当比例的bug其中最典型的就是死锁 —— 当一个线程试图使用一个永远不会被释放的锁时,这个线程就会被永久性挂起。
我们可以在内核里主动引发一次死锁看看,请回忆一下,我们的 `println` 宏调用了 `vga_buffer::_print` 函数,而这个函数又使用了 [`WRITER`][vga spinlock] 变量,该变量被定义为带同步锁的变量:
[vga spinlock]: @/edition-2/posts/03-vga-text-buffer/index.md#spinlocks
```rust
// in src/vga_buffer.rs
[]
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
WRITER.lock().write_fmt(args).unwrap();
}
```
获取到 `WRITER` 变量的锁后,调用其内部的 `write_fmt` 函数,然后在结尾隐式解锁该变量。但是假如在函数执行一半的时候,中断处理函数触发,同样试图打印日志的话:
| Timestep | _start | interrupt_handler |
| -------- | ---------------------- | ----------------------------------------------- |
| 0 | calls `println!` | &nbsp; |
| 1 | `print` locks `WRITER` | &nbsp; |
| 2 | | **interrupt occurs**, handler begins to run |
| 3 | | calls `println!` |
| 4 | | `print` tries to lock `WRITER` (already locked) |
| 5 | | `print` tries to lock `WRITER` (already locked) |
| … | | … |
| _never_ | _unlock `WRITER`_ |
`WRITER` 被锁定,所以中断处理函数就会一直等待到它被解锁为止,然而后续永远不会发生了,因为只有当中断处理函数返回,`_start` 函数才会继续运行,`WRITER` 才可能被解锁,所以整个系统就这么挂起了。
### 引发死锁
基于这个原理,我们可以通过在 `_start` 函数中构建一个输出循环来很轻易地触发死锁:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
loop {
use blog_os::print;
print!("-"); // new
}
}
```
在QEMU中运行后输出是这样的
![QEMU output with many rows of hyphens and no dots](./qemu-deadlock.png)
我们可以看到,这段程序只输出了有限的中划线,在第一次计时器中断触发后就不再动弹了,这是因为计时器中断对应的处理函数触发了输出宏中潜在的死锁,这也是为什么我们没有在上面的输出中看到点号的原因。
由于计时器中断是完全异步的所以每次运行能够输出的中划线数量都是不确定的这种特性也导致和并发相关的bug非常难以调试。
### 修复死锁
要避免死锁,我们可以在 `Mutex` 被锁定时禁用中断:
```rust
// in src/vga_buffer.rs
/// Prints the given formatted string to the VGA text buffer
/// through the global `WRITER` instance.
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // new
interrupts::without_interrupts(|| { // new
WRITER.lock().write_fmt(args).unwrap();
});
}
```
[`without_interrupts`] 函数可以使一个 [闭包][closure] 代码块在无中断环境下执行,由此我们可以让 `Mutex` 变量在锁定期间的执行逻辑不会被中断信号打断。再次运行我们的内核,此时程序就不会被挂起了。(然而我们依然不会看到任何点号,因为输出速度实在是太快了,试着降低一下输出速度就可以了,比如在循环里插入一句 `for _ in 0..10000 {}`。)
[`without_interrupts`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/interrupts/fn.without_interrupts.html
[closure]: https://doc.rust-lang.org/book/ch13-01-closures.html
我们也可以在串行输出函数里也加入同样的逻辑来避免死锁:
```rust
// in src/serial.rs
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
use x86_64::instructions::interrupts; // new
interrupts::without_interrupts(|| { // new
SERIAL1
.lock()
.write_fmt(args)
.expect("Printing to serial failed");
});
}
```
但请注意,禁用中断不应是被广泛使用的手段,它可能会造成中断的处理延迟增加,比如操作系统是依靠中断信号进行计时的。因此,中断仅应在极短的时间内被禁用。
## 修复竞态条件
如果你运行 `cargo test` 命令,则会发现`test_println_output` 测试执行失败:
```
> cargo test --lib
[…]
Running 4 tests
test_breakpoint_exception...[ok]
test_println... [ok]
test_println_many... [ok]
test_println_output... [failed]
Error: panicked at 'assertion failed: `(left == right)`
left: `'.'`,
right: `'S'`', src/vga_buffer.rs:205:9
```
其原因就是测试函数和计时器中断处理函数出现了 _竞态条件_,测试函数是这样的:
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
let s = "Some test string that fits on a single line";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}
```
该测试将一串字符打印到VGA缓冲区并通过一个循环检测 `buffer_chars` 数组的内容。竞态条件出现的原因就是在 `println` 和检测逻辑之间触发了计时器中断,其处理函数同样调用了输出语句。不过这并非危险的 _数据竞争_该种竞争可以被Rust语言在编译期完全避免。如果你对此感兴趣可以查阅一下 [_Rustonomicon_][nomicon-races]。
[nomicon-races]: https://doc.rust-lang.org/nomicon/races.html
要修复这个问题,我们需要让 `WRITER` 加锁的范围扩大到整个测试函数,使计时器中断处理函数无法输出 `.`,就像这样:
```rust
// in src/vga_buffer.rs
#[test_case]
fn test_println_output() {
use core::fmt::Write;
use x86_64::instructions::interrupts;
let s = "Some test string that fits on a single line";
interrupts::without_interrupts(|| {
let mut writer = WRITER.lock();
writeln!(writer, "\n{}", s).expect("writeln failed");
for (i, c) in s.chars().enumerate() {
let screen_char = writer.buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
});
}
```
我们进行了如下修改:
- 我们使用 `lock()` 函数显式加锁,然后将 `println` 改为 [`writeln`] 宏,以此绕开输出必须加锁的限制。
- 为了避免死锁,我们同时在测试函数执行期间禁用中断,否则中断处理函数可能会意外被触发。
- 为了防止在测试执行前计时器中断被触发所造成的干扰,我们先输出一句 `\n`,即可避免行首出现多余的 `.` 造成的干扰。
[`writeln`]: https://doc.rust-lang.org/core/macro.writeln.html
经过以上修改,`cargo test` 就可以正确运行了。
好在这是一种十分无害的竞态条件仅仅会导致测试失败但如你所想其它形式的竞态条件可能会更加难以调试。幸运的是更加恶性的数据竞争已经被Rust从根本上避免了大部分数据竞争都会造成无法预知的行为比如系统崩溃或者悄无声息的内存破坏。
## `hlt` 指令
目前我们在 `_start``panic` 函数的末尾都使用了一个空白的循环这的确能让整体逻辑正常运行但也会让CPU全速运转 —— 尽管此时并没有什么需要计算的工作。如果你在执行内核时打开任务管理器便会发现QEMU的CPU占用率全程高达100%。
但是我们可以让CPU在下一个中断触发之前休息一下也就是进入休眠状态来节省一点点能源。[`hlt` instruction][`hlt` 指令] 可以让我们做到这一点,那就来用它写一个节能的无限循环:
[`hlt` instruction]: https://en.wikipedia.org/wiki/HLT_(x86_instruction)
```rust
// in src/lib.rs
pub fn hlt_loop() -> ! {
loop {
x86_64::instructions::hlt();
}
}
```
`instructions::hlt` 只是对应汇编指令的 [薄包装][thin wrapper],并且它是内存安全的,没有破坏内存的风险。
[thin wrapper]: https://github.com/rust-osdev/x86_64/blob/5e8e218381c5205f5777cb50da3ecac5d7e3b1ab/src/instructions/mod.rs#L16-L22
现在我们来试着在 `_start``panic` 中使用 `hlt_loop`
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
[]
println!("It did not crash!");
blog_os::hlt_loop(); // new
}
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
blog_os::hlt_loop(); // new
}
```
接下来再更新一下 `lib.rs`
```rust
// in src/lib.rs
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
init();
test_main();
hlt_loop(); // new
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
hlt_loop(); // new
}
```
再次在QEMU中执行我们的内核CPU使用率已经降低到了比较低的水平了。
## 键盘输入
现在,我们已经知道了如何接收外部设备的中断信号,我们可以进一步对键盘添加支持,由此我们可以与内核进行交互。
<aside class="post_aside">
注意,我们仅仅会讲解 [PS/2] 键盘的兼容方式而非USB键盘。不过好在主板往往会将USB键盘模拟为 PS/2 设备来支持旧时代的软件所以我们暂时可以不考虑USB键盘这种情况。
</aside>
[PS/2]: https://en.wikipedia.org/wiki/PS/2_port
就如同硬件计时器一样键盘控制器也是默认启用的所以当你敲击键盘上某个按键时键盘控制器就会经由PIC向CPU发送中断信号。然而CPU此时是无法在IDT找到相关的中断处理函数的所以 double fault 异常会被抛出。
所以我们需要为键盘中断添加一个处理函数,它十分类似于计时器中断处理的实现,只不过需要对中断编号做出一点小小的修改:
```rust
// in src/interrupts.rs
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
Keyboard, // new
}
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[]
// new
idt[InterruptIndex::Keyboard.as_usize()]
.set_handler_fn(keyboard_interrupt_handler);
idt
};
}
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
print!("k");
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
[上文](#the-8259-pic) 提到键盘使用的是主PIC的1号管脚在CPU的中断编号为331 + 偏移量32。我们需要在 `InterruptIndex` 枚举类型里添加一个 `Keyboard`但是无需显式指定对应值因为在默认情况下它的对应值是上一个枚举对应值加一也就是33。在处理函数中我们先输出一个 `k`,并发送结束信号来结束中断。
现在当我们按下任意一个按键,就会在屏幕上输出一个 `k`,然而这只会生效一次,因为键盘控制器在我们 _获取扫描码_ 之前,是不会发送下一个中断的。
### 读取扫描码
要找到哪个按键被按下,我们还需要询问一下键盘控制器,我们可以从 PS/2 控制器(即地址为 `0x60` 的 [I/O端口][I/O port])的数据端口获取到该信息:
[I/O port]: @/edition-2/posts/04-testing/index.md#i-o-ports
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
print!("{}", scancode);
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
我们使用了 `x86_64` crate 中的 [`Port`] 来从键盘数据端口中读取名为 [_扫描码_] 的随着按键按下/释放而不断变化的数字。我们暂且不处理它,只是在屏幕上打印出来:
[`Port`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/port/struct.Port.html
[_scancode_]: https://en.wikipedia.org/wiki/Scancode
![QEMU printing scancodes to the screen when keys are pressed](qemu-printing-scancodes.gif)
在上图中,演示的正是缓慢输入 `123` 的结果。我们可以看到,相邻的按键具备相邻的扫描码,而按下按键和松开按键也会出现不同的扫描码,那么问题来了,我们该如何对这些扫描码进行译码?
### 扫描码转义
关于按键与键位码之间的映射关系,目前存在三种不同的标准(所谓的 _扫描码映射集_。三种标准都可以追溯到早期的IBM电脑键盘[IBM XT]、 [IBM 3270 PC]和[IBM AT]。好在之后的电脑并未另起炉灶定义新的扫描码映射集,但也对现有类型进行模拟并加以扩展,如今的绝大多数键盘都可以模拟成这三种类型之一。
[IBM XT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer_XT
[IBM 3270 PC]: https://en.wikipedia.org/wiki/IBM_3270_PC
[IBM AT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer/AT
默认情况下PS/2 键盘会模拟Set-1XT在该布局下扫描码的低7位表示按键而其他的比特位则定义了是按下0还是释放1。不过这些按键并非都存在于原本的 [IBM XT] 键盘上,比如小键盘的回车键,此时就会连续生成两个扫描码:`0xe0` 以及一个自定义的代表该键位的数字。[OSDev Wiki][scancode set 1] 可以查阅到Set-1下的扫描码对照表。
[scancode set 1]: https://wiki.osdev.org/Keyboard#Scan_Code_Set_1
要将扫描码译码成按键我们可以用一个match匹配语句
```rust
// in src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
// new
let key = match scancode {
0x02 => Some('1'),
0x03 => Some('2'),
0x04 => Some('3'),
0x05 => Some('4'),
0x06 => Some('5'),
0x07 => Some('6'),
0x08 => Some('7'),
0x09 => Some('8'),
0x0a => Some('9'),
0x0b => Some('0'),
_ => None,
};
if let Some(key) = key {
print!("{}", key);
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
以上代码可以对数字按键0-9进行转义并忽略其他键位。具体到程序逻辑中就是使用 [match] 匹配映射数字0-9对于其他扫描码则返回 `None`,然后使用 [`if let`] 语句对 `key` 进行解构取值,在这个语法中,代码块中的 `key` 会 [遮蔽][shadow] 掉代码块外的同名 `Option` 型变量。
[match]: https://doc.rust-lang.org/book/ch06-02-match.html
[`if let`]: https://doc.rust-lang.org/book/ch18-01-all-the-places-for-patterns.html#conditional-if-let-expressions
[shadow]: https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing
现在我们就可以向控制台写入数字了:
![QEMU printing numbers to the screen](qemu-printing-numbers.gif)
其他扫描码也可以通过同样的手段进行译码,不过真的很麻烦,好在 [`pc-keyboard`] crate 已经帮助我们实现了Set-1和Set-2的译码工作所以无需自己去实现。所以我们只需要将下述内容添加到 `Cargo.toml`,并在 `lib.rs` 里进行引用:
[`pc-keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/
```toml
# in Cargo.toml
[dependencies]
pc-keyboard = "0.5.0"
```
现在我们可以使用新的crate对 `keyboard_interrupt_handler` 进行改写:
```rust
// in/src/interrupts.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: InterruptStackFrame)
{
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
use x86_64::instructions::port::Port;
lazy_static! {
static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
Mutex::new(Keyboard::new(layouts::Us104Key, ScancodeSet1,
HandleControl::Ignore)
);
}
let mut keyboard = KEYBOARD.lock();
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => print!("{}", character),
DecodedKey::RawKey(key) => print!("{:?}", key),
}
}
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
```
首先我们使用 `lazy_static` 宏创建一个受到Mutex同步锁保护的 [`Keyboard`] 对象初始化参数为美式键盘布局以及Set-1。至于 [`HandleControl`],它可以设定为将 `ctrl+[a-z]` 映射为Unicode字符 `U+0001``U+001A`,但我们不想这样,所以使用了 `Ignore` 选项让 `ctrl` 仅仅表现为一个正常键位。
[`HandleControl`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/enum.HandleControl.html
对于每一个中断,我们都会为 KEYBOARD 加锁,从键盘控制器获取扫描码并将其传入 [`add_byte`] 函数,并将其转化为 `Option<KeyEvent>` 结构。[`KeyEvent`] 包括了触发本次中断的按键信息,以及子动作是按下还是释放。
[`Keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html
[`add_byte`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.add_byte
[`KeyEvent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.KeyEvent.html
要处理KeyEvent我们还需要将其传入 [`process_keyevent`] 函数,将其转换为人类可读的字符,若果有必要,也会对字符进行一些处理。典型例子就是,要判断 `A` 键按下后输入的是小写 `a` 还是大写 `A`这要取决于shift键是否同时被按下。
[`process_keyevent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.process_keyevent
进行这些修改之后,我们就可以正常输入英文了:
![Typing "Hello World" in QEMU](qemu-typing.gif)
### 配置键盘
PS/2 键盘可以配置的地方其实还有很多比如设定它使用何种扫描码映射集然而这篇文章已经够长了就不在此展开说明如果有兴趣可以在OSDev wiki查看[更详细的资料][configuration commands]。
[configuration commands]: https://wiki.osdev.org/PS/2_Keyboard#Commands
## 小结
本文描述了如何启用并处理外部中断。我们学习了关于8259 PIC的主副布局、重映射中断编号以及结束中断信号的基础知识实现了简单的硬件计时器和键盘的中断处理器以及如何使用 `hlt` 指令让CPU休眠至下次接收到中断信号。
现在我们已经可以和内核进行交互,满足了创建简易控制台或简易游戏的基础条件。
## 下文预告
计时器中断对操作系统而言至关重要,它可以使内核定期重新获得控制权,由此内核可以对线程进行调度,创造出多个线程并行执行的错觉。
然而在我们创建进程或线程之前,我们还需要解决内存分配问题。下一篇文章中,我们就会对内存管理进行阐述,以提供后续功能会使用到的基础设施。

View File

@@ -1,421 +0,0 @@
+++
title = "مقدمه‌ای بر صفحه‌بندی"
weight = 8
path = "fa/paging-introduction"
date = 2019-01-14
[extra]
chapter = "Memory Management"
# Please update this when updating the translation
translation_based_on_commit = "f692c5b377460e872bca2d3fcec787f4a0d1ec9b"
# GitHub usernames of the people that translated this post
translators = ["hamidrezakp", "MHBahrampour"]
rtl = true
+++
در این پست _صفحه‌بندی_، یک طرح مدیریت حافظه بسیار رایج که ما نیز برای سیستم‌عامل خود استفاده خواهیم کرد، معرفی می‌شود. این پست توضیح می‌دهد که چرا ایزوله سازی حافظه مورد نیاز است، قطعه‌بندی چگونه کار می‌کند، _حافظه مجازی_ چیست و چگونه صفحه‌بندی مشکلات تقسیم حافظه را حل می کند. همچنین طرح جدول‌های صفحه چند سطحی را در معماری x86_64 بررسی می‌کند.
<!-- more -->
این بلاگ بصورت آزاد روی [گیت‌هاب] توسعه داده شده است. اگر شما مشکل یا سوالی دارید، لطفاً آن‌جا یک ایشو باز کنید. شما همچنین می‌توانید [در زیر] این پست کامنت بگذارید. منبع کد کامل این پست را می‌توانید در بِرَنچ [`post-08`][post branch] پیدا کنید.
[گیت‌هاب]: https://github.com/phil-opp/blog_os
[در زیر]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-08
<!-- toc -->
## محافظت از حافظه
یکی از وظایف اصلی یک سیستم‌عامل جداسازی (ایزوله کردن) برنامه‌ها از یکدیگر است. به عنوان مثال، مرورگر وب شما نباید در کار ویرایشگر متن تداخلی ایجاد کند. برای دستیابی به این هدف، سیستم‌عامل‌ها از قابلیتی سخت‌افزاری استفاده کرده تا اطمینان حاصل کنند که حافظه مربوط به یک پروسه، توسط پروسه‌ای دیگر غیر قابل دسترس است. رویکردهای مختلفی وجود دارد که به سخت‌افزار و پیاده‌سازی سیستم عامل بستگی دارد.
به عنوان مثال‌، برخی از پردازنده‌های ARM Cortex-M (برای سیستم‌های تعبیه شده استفاده می‌شوند) دارای یک [_واحد محافظت از حافظه_] (Memory Protection Unit: MPU) هستند، که به شما این امکان را می‌دهد که تعداد کمی از ناحیه حافظه (مانند 8) را با مجوزهای دسترسی متفاوت تعریف کنید (به عنوان مثال عدم دسترسی، فقط خواندنی، خواندنی-نوشتنی). در هر دسترسی به حافظه، MPU اطمینان حاصل می‌کند که آدرس در ناحیه‌ای با مجوزهای دسترسی صحیح قرار دارد و در غیر این‌صورت یک استثنا ایجاد می‌کند. با تغییر ناحیه و مجوزهای دسترسی در هر تعویض پروسه (ترجمه: process switch)، سیستم‌عامل می‌تواند اطمینان حاصل کند که هر پروسه فقط به حافظه خود دسترسی پیدا می‌کند و بنابراین پروسه‌ها را ایزوله می‌کند.
[_واحد محافظت از حافظه_]: https://developer.arm.com/docs/ddi0337/e/memory-protection-unit/about-the-mpu
در x86، سخت‌افزار از دو روش مختلف برای محافظت از حافظه پشتیبانی می‌کند: [قطعه‌بندی] و [صفحه‌بندی].
[قطعه‌بندی]: https://en.wikipedia.org/wiki/X86_memory_segmentation
[صفحه‌بندی]: https://en.wikipedia.org/wiki/Virtual_memory#Paged_virtual_memory
## قطعه‌بندی
قطعه‌بندی قبلاً در سال 1978 برای افزایش میزان حافظه‌‌ی آدرس پذیر معرفی شده بود. وضعیت در آن زمان این بود که پردازنده‌ها فقط از آدرس‌های 16 بیتی استفاده می‌کردند که باعث کاهش حافظه آدرس پذیر به 64KiB می‌شد. برای دسترسی بیشتر از این 64KiB، ثبات‌های قطعه‌ی اضافی معرفی شدند که هر کدام حاوی یک offset هستند. پردازنده به طور خودکار این آفست را بر روی هر دسترسی به حافظه اضافه می‌کند، بنابراین حداکثر ۱ مگابایت حافظه قابل دسترسی است.
بسته به نوع دسترسی به حافظه، ثبات قطعه به طور خودکار توسط پردازنده انتخاب می‌شود: برای دستورالعمل‌های واکشی (ترجمه: fetching)، از کد `CS` و برای عملیات‌های پشته (push/pop) پشته قطعه `SS` استفاده می‌شود. سایر دستورالعمل‌ها ازقطعه‌ی داده `DS` یا قطعه‌ی اضافه `ES` استفاده می‌کنند. بعدها دو ثبات قطعه‌ی اضافی `FS` و `GS` اضافه شدند که می‌توانند آزادانه مورد استفاده قرار گیرند.
در نسخه اول قطعه‌بندی، ثبات‌های قطعه مستقیماً شامل آفست بودند و هیچ كنترل دسترسی انجام نمی‌شد. بعدها با معرفی [_حالت محافظت شده_] این مورد تغییر کرد. هنگامی که پردازنده در این حالت اجرا می‌شود، توصیف کنندگان قطعه شامل یک فهرست در یک [_جدول توصیفکننده_] محلی یا سراسری هستند - که علاوه بر آدرس آفست - اندازه و مجوزهای دسترسی را نیز در خود دارد. با بارگذاری جدول‌‌های توصیف‌کننده سراسری/محلی برای هر فرآیند که دسترسی حافظه را به ناحیه حافظه خود فرآیند محدود می‌کند، سیستم‌عامل می‌تواند فرایندها را از یکدیگر جدا کند.
[_حالت محافظت شده_]: https://en.wikipedia.org/wiki/X86_memory_segmentation#Protected_mode
[_جدول توصیفکننده_]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
با اصلاح آدرس‌های حافظه قبل از دسترسی واقعی، قطعه‌بندی از تکنیکی استفاده کرده است که اکنون تقریباً در همه جا استفاده می شود: _حافظه مجازی‌_.
### حافظه مجازی
ایده پشت حافظه مجازی این است که آدرس‌های حافظه را از دستگاه ذخیره‌سازی فیزیکی زیرین، دور کنید. به جای دسترسی مستقیم به دستگاه ذخیره‌سازی، ابتدا مرحله ترجمه انجام می‌شود. برای قطعه‌بندی، مرحله ترجمه، افزودن آدرس آفست قطعه‌ی فعال است. تصور کنید یک برنامه به آدرس حافظه `0x1234000` در قطعه‌ای با آفست` 0x1111000` دسترسی پیدا کند: آدرسی که واقعاً قابل دسترسی است `0x2345000` است.
برای تمایز بین دو نوع آدرس، به آدرس‌های قبل از ترجمه _مجازی_ و به آدرس‌های بعد از ترجمه _فیزیکی_ گفته می‌شود. یک تفاوت مهم بین این دو نوع آدرس این است که آدرس‌های فیزیکی منحصربه‌فرد هستند و همیشه به همان مکان حافظه متمایز اشاره دارند. از طرف دیگر آدرس‌های مجازی به تابع ترجمه بستگی دارد. کاملاً ممکن است که دو آدرس مجازی مختلف به همان آدرس فیزیکی اشاره داشته باشند. همچنین، آدرس‌های مجازی یکسان می‌توانند هنگام استفاده از توابع ترجمه مختلف، به آدرس‌های فیزیکی مختلفی مراجعه کنند.
برای مثال هنگامی که می‌خواهید یک برنامه را دو بار بصورت موازی اجرا کنید، این خاصیت مفید است.
![Two virtual address spaces with address 0150, one translated to 100250, the other to 300450](segmentation-same-program-twice.svg)
در اینجا همان برنامه دو بار اجرا می‌شود ، اما با تابع‌های ترجمه مختلف. نمونه اول دارای آفست قطعه 100 است، بنابراین آدرس‌های مجازی 0150 به آدرس های فیزیکی 100250 ترجمه می‌شوند. نمونه دوم دارای آفست قطعه 300 است، که آدرس‌های مجازی 0150 را به آدرس‌های فیزیکی 300450 ترجمه می‌کند. این به هر دو برنامه این امکان را می‌دهد تا بدون تداخل با یکدیگر کد یکسانی را اجرا کنند و از آدرس‌های مجازی یکسان استفاده کنند.
مزیت دیگر این است که برنامه‌ها می‌توانند در مکان‌های حافظه فیزیکی دلخواه قرار بگیرند، حتی اگر از آدرس‌های مجازی کاملاً متفاوتی استفاده کنند. بنابراین، سیستم‌عامل می‌تواند از مقدار کامل حافظه موجود بدون نیاز به کامپایل مجدد برنامه‌ها استفاده کند.
### تکه‌تکه شدن
تمایز بین آدرس‌های مجازی و فیزیکی قطعه‌بندی را واقعا قدرتمند می‌کند. با این حال، مشکل تکه‌تکه شدن (ترجمه: fragmentation) دارد. به عنوان مثال، تصور کنید که می‌خواهیم نسخه سوم برنامه‌ای را که در بالا دیدیم اجرا کنیم:
![Three virtual address spaces, but there is not enough continuous space for the third](segmentation-fragmentation.svg)
هیچ راهی برای نگاشت کردن نمونه سوم برنامه روی حافظه مجازی بدون همپوشانی وجود ندارد، حتی اگر حافظه آزاد بیش از اندازه کافی در دسترس باشد. مشکل این است که ما به حافظه _یکپارچه_ نیاز داریم و نمی‌توانیم از تکه‌های کوچک استفاده کنیم.
یکی از راه‌های مقابله با این تکه‌تکه شدن، وقفه/مکث (pause) در اجرا است، انتقال قسمت‌های استفاده شده حافظه به سمت یکدیگر تا این قسمت‌ها به هم بچسبند و فضای تکه‌تکه شده بین آن‌ها پر شود، سپس به روزرسانی ترجمه و اجرای مجدد آن است:
![Three virtual address spaces after defragmentation](segmentation-fragmentation-compacted.svg)
اکنون فضای یکپارچه کافی برای شروع نمونه سوم برنامه ما وجود دارد.
نقطه ضعف این فرآیند یکپارچه‌سازی (ترجمه: defragmentation) قطعات این است که نیاز به کپی کردن مقدار زیادی حافظه است که باعث کاهش کارایی می‌شود. همچنین لازم است قبل از اینکه حافظه بیش از حد تکه‌تکه شود، این کار به طور منظم انجام شود. این باعث می‌شود کارایی غیرقابل پیش‌بینی باشد، زیرا برنامه‌ها به طور تصادفی دچار وقفه می‌شوند و ممکن است ناپاسخگو (ترجمه: unresponsive) شوند.
مشکل تکه‌تکه شدن یکی از دلایلی است که قطعه‌بندی دیگر توسط اکثر سیستم‌ها استفاده نمی‌شود. در واقع‌، قطعه‌بندی حتی در حالت 64 بیتی روی x86 دیگر پشتیبانی نمی‌شود. در عوض از _صفحه‌بندی_ استفاده می‌شود، که به طور کامل از مشکل تکه‌تکه شدن جلوگیری می‌کند.
## صفحه‌بندی
ایده این است که هر دو فضای حافظه مجازی و فیزیکی را به بلوک‌های کوچک و با اندازه ثابت تقسیم کنید. بلوک‌های فضای حافظه مجازی _صفحه‌ها_ و بلوک‌های فضای آدرس فیزیکی _قاب‌ها_ نامیده می‌شوند. هر صفحه را می‌توان به صورت جداگانه به یک قاب نگاشت کرد‌، که باعث می‌شود ناحیه حافظه بزرگتر در قاب‌های فیزیکی غیر یکپارچه تقسیم شوند.
اگر مثالِ فضای حافظه تکه‌تکه شده را خلاصه کنیم، مزیت این امر قابل مشاهده می‌شود، اما این بار به جای قطعه‌بندی از صفحه‌بندی استفاده می‌کنیم:
![With paging the third program instance can be split across many smaller physical areas](paging-fragmentation.svg)
در این مثال یک صفحه با اندازه 50 بایت داریم، به این معنی که هر یک از ناحیه حافظه ما در سه صفحه تقسیم شده است. هر صفحه به صورت جداگانه به یک قاب نگاشت می‌شود، بنابراین می‌توان یک منطقه حافظه مجازی یکپارچه را به قاب‌های فیزیکی غیر یکپارچه نگاشت کرد. که به ما این امکان را می‌دهد تا نمونه سوم برنامه را بدون انجام هرگونه یکپارچه‌سازی شروع کنیم.
### تکه‌تکه شدن مخفی
در مقایسه با قطعه‌بندی‌، صفحه‌بندی به جای چند منطقه بزرگ و متغیر، از تعداد زیادی ناحیه حافظه کوچک و ثابت استفاده می‌کند. از آن‌جا که هر قاب دارای اندازه یکسانی است، هیچ قابی وجود ندارد که از سایز صفحه‌های موجود کوچکتر باشد، پس تکه‌تکه شدن رخ نمی‌دهد.
یا _به نظر_ می‌رسد که هیچ تکه‌تکه‌ شدنی رخ نمی‌دهد. هنوز یک نوع تکه‌تکه‌ شدن نخفی وجود دارد، به اصطلاح _تکه‌تکه شدن داخلی_. تکه‌تکه شدن داخلی اتفاق می‌افتد زیرا همه ناحیه حافظه دقیقاً مضربی از اندازه صفحه نیستند. برنامه‌ای با اندازه 101 را در مثال بالا تصور کنید: هنوز به سه صفحه با اندازه 50 نیاز دارد، بنابراین 49 بایت بیش از حد مورد نیاز اشغال می‌کند. برای تمایز بین دو نوع تکه‌تکه‌ شدن، نوعی تکه‌تکه‌ شدنی که هنگام استفاده از قطعه‌بندی اتفاق می‌افتد، _قطعه‌بندی خارجی_ نامیده می‌شود.
تکه‌تکه شدن داخلی تأسف آور است، اما اغلب بهتر از تکه‌تکه شدن خارجی است که با قطعه‌بندی رخ می‌دهد. این هنوز حافظه را هدر می‌دهد، اما به یکپارچه‌سازی نیاز ندارد و میزان تکه‌تکه شدن را قابل پیش‌بینی می‌کند (به طور متوسط نیم صفحه در هر منطقه حافظه).
### جدول صفحه‌ها
دیدیم که هر یک از میلیون‌ها صفحه بالقوه به صورت جداگانه در یک قاب نگاشت می‌شوند. این اطلاعات نگاشت باید در جایی ذخیره شود. قطعه‌بندی برای هر منطقه حافظه فعال از یک ثبات انتخابگرِ قطعه‌ی جداگانه استفاده می‌کند، که برای صفحه‌بندی امکان پذیر نیست زیرا صفحات بیشتری نسبت به ثبات‌ها وجود دارد. در عوض صفحه‌بندی از یک ساختار جدول به نام _page table_ برای ذخیره اطلاعات نگاشت استفاده می کند.
برای مثال بالا، جدول‌های صفحه به صورت زیر است:
![Three page tables, one for each program instance. For instance 1 the mapping is 0->100, 50->150, 100->200. For instance 2 it is 0->300, 50->350, 100->400. For instance 3 it is 0->250, 50->450, 100->500.](paging-page-tables.svg)
می‌بینیم که هر نمونه‌ی برنامه جدول صفحه خاص خود را دارد. یک اشاره‌گر به جدولی که در حال حاضر فعال است، در یک رجیستر مخصوص CPU ذخیره می‌شود. در `x86`، این ثبات `CR3` است. وظیفه سیستم‌عامل این است که قبل از اجرای هر نمونه‌ی برنامه، این رجیستر را با اشاره‌گر به جدول صفحه‌ی صحیح بارگذاری کند.
در هر دسترسی به حافظه، CPU اشاره‌گر جدول را از ثبات می‌خواند و قاب نگاشته شده را برای صفحه قابل دسترسی در جدول جستجو می‌کند. این کار کاملاً بصورت سخت‌افزاری و کاملاً شفاف برای برنامه‌ی در حال اجرا، انجام می‌شود. برای سرعت بخشیدن به روند ترجمه، بسیاری از معماری‌های CPU حافظه پنهان (ترجمه: cache) ویژه‌ای دارند که نتایج آخرین ترجمه‌ها را به خاطر می‌سپارد.
بسته به معماری، ورودی‌های جدول صفحه همچنین می‌توانند ویژگی‌هایی مانند مجوزهای دسترسی را در فیلد پرچم‌ها ذخیره کنند. در مثال بالا، پرچم "r/w" صفحه را، خواندنی و قابل نوشتن می‌کند.
### جدول های صفحه چند سطحی
جدول‌های صفحه ساده که اخیراً دیدیم در فضاهای آدرس بزرگتر مشکل دارند: آن‌ها حافظه را هدر می‌دهند. به عنوان مثال، برنامه‌ای را تصور کنید که از چهار صفحه مجازی `0`، `000_000_1`، `050_000_1` و `100_000_1` استفاده کند (ما از `_` به عنوان جداکننده هزاران استفاده می‌کنیم):
![Page 0 mapped to frame 0 and pages `1_000_000``1_000_150` mapped to frames 100250](single-level-page-table.svg)
این فقط به 4 قاب فیزیکی نیاز دارد، اما جدول صفحه بیش از یک میلیون ورودی دارد. ما نمی‌توانیم ورودی‌های خالی را حذف کنیم زیرا در این صورت CPU دیگر نمی‌تواند مستقیماً به ورودی صحیح در فرآیند ترجمه پرش کند (به عنوان مثال، دیگر تضمین نمی‌شود که صفحه چهارم از ورودی چهارم استفاده کند).
برای کاهش حافظه هدر رفته، می‌توانیم از یک **جدول صفحه دو سطحی** استفاده کنیم. ایده این است که ما از جدول‌های صفحه مختلف برای ناحیه آدرس مختلف استفاده می‌کنیم. یک جدول اضافی با عنوان جدول صفحه _level 2_ شامل نگاشت بین ناحیه آدرس و جدول‌های صفحه (سطح 1) است.
این بهتر است با یک مثال توضیح داده شود. بیایید تعریف کنیم که هر جدول صفحه 1 سطح مربوط به منطقه‌ای با اندازه `000_10` است. سپس جدول‌های زیر برای مثال نگاشت بالا وجود دارد:
![Page 0 points to entry 0 of the level 2 page table, which points to the level 1 page table T1. The first entry of T1 points to frame 0, the other entries are empty. Pages `1_000_000``1_000_150` point to the 100th entry of the level 2 page table, which points to a different level 1 page table T2. The first three entries of T2 point to frames 100250, the other entries are empty.](multilevel-page-table.svg)
صفحه 0 در اولین بایت منطقه `000_10` قرار می‌گیرد، بنابراین از اولین ورودی جدول صفحه سطح 2 استفاده می‌کند. این ورودی به جدول صفحه 1 سطح T1 اشاره دارد که مشخص می کند صفحه `0` به قاب `0` اشاره می‌کند.
صفحات `000_000_1` ،`050_000_1` و `100_000_1` همگی در منطقه صدم `000_10` بایت قرار می‌گیرند، بنابراین آن‌ها از ورودی صدم در جدول صفحه سطح 2 استفاده می‌کنند. این ورودی در جدول سطح 1 صفحه T2 متفاوت است که سه صفحه را با قاب‌های `100`، `150` و `200` نگاشت می‌کند. توجه داشته باشید که آدرس صفحه در جدول‌‌های سطح 1 شامل آفست منطقه نیست، به عنوان مثال، ورودی صفحه `050_000_1` فقط `50` است.
ما هنوز 100 ورودی خالی در جدول سطح 2 داریم، اما بسیار کمتر از یک میلیون ورودی خالیِ قبل است. دلیل این پس‌انداز این است که نیازی به ایجاد جدول‌های صفحه سطح 1 برای ناحیه حافظه نگاشت نشده بین `000_10` و `000_000_1` نداریم.
قاعده جدول‌های صفحه دو سطحی را می‌توان به سه، چهار یا بیشتر سطح گسترش داد. سپس ثبات جدول صفحه به جدول بالاترین سطح اشاره می‌کند، که به جدول سطح پایین بعدی اشاره می‌کند، که به سطح پایین بعدی اشاره می‌کند و این روال ادامه پیدا می‌کند. جدول صفحه سطح 1 سپس به قاب نگاشته شده اشاره می‌کند. این قاعده را به صورت کلی،‌ جدول صفحات _چند سطحی_ \(ترجمه: multilevel) یا _سلسله مراتبی‌_ \(ترجمه: hierarchical) می‌نامند.
اکنون که از نحوه کار جدول‌های صفحه‌بندی و صفحه‌های چند سطحی مطلع شدیم، می‌توانیم به نحوه پیاده‌سازی در معماری x86_64 توجه کنیم (در ادامه فرض می‌کنیم CPU در حالت 64 بیتی کار می‌کند).
## صفحه‌بندی در x86_64
معماری x86_64 از جدول صفحه 4 سطحی و اندازه صفحه 4KiB استفاده می‌کند. هر جدول صفحه، مستقل از سطح، دارای اندازه ثابت 512 ورودی است. اندازه هر ورودی 8 بایت است، پس بزرگی هر جدول 8B * 512 = 4KiB است و بنابراین دقیقاً در یک صفحه قرار می‌گیرد.
اندیس جدول صفحه برای سطح مستقیماً از آدرس مجازی مشتق می‌شود:
![Bits 012 are the page offset, bits 1221 the level 1 index, bits 2130 the level 2 index, bits 3039 the level 3 index, and bits 3948 the level 4 index](x86_64-table-indices-from-address.svg)
می‌بینیم که هر اندیس جدول از 9 بیت تشکیل شده است، که منطقی است زیرا هر جدول دارای 512 = 9^2 ورودی است. کمترین 12 بیت در صفحه 4KiB آفست هستند (2^12 بایت = 4 کیلوبایت). بیت های 48 تا 64 کنار گذاشته می‌شوند، به این معنی که x86_64 در واقع 64 بیتی نیست زیرا فقط از آدرس های 48 بیتی پشتیبانی می‌کند.
[جدول صفحه 5 سطحی]: https://en.wikipedia.org/wiki/Intel_5-level_paging
حتی اگر بیت‌های 48 تا 64 کنار گذاشته‌شوند، نمی‌توان آن‌ها را روی مقادیر دلخواه تنظیم کرد. در عوض، همه بیت‌های این محدوده باید کپی از بیت 47 باشند تا آدرس‌ها منحصربه‌فرد باشند و extension های آینده مانند [جدول صفحه 5 سطحی] را ممکن کنند. این _sign-extension_ نامیده می‌شود زیرا بسیار شبیه به [extension علامت در مکمل دو] است. وقتی آدرس به درستی امضا نشده باشد، CPU یک استثنا را ارائه می‌دهد.
[extension علامت در مکمل دو]: https://en.wikipedia.org/wiki/Two's_complement#Sign_extension
شایان ذکر است که پردازنده‌های اخیر "Ice Lake" اینتل به صورت اختیاری از [جدول‌های صفحه 5 سطحی] پشتیبانی می‌کنند تا آدرس‌های مجازی را از 48 بیتی به 57 بیتی گسترش دهند. با توجه به این‌که بهینه‌سازی هسته ما برای یک CPU خاص در این مرحله منطقی نیست، ما در این پست فقط با جدول‌های صفحه 4 سطحیِ استاندارد کار خواهیم کرد.
[جدول‌های صفحه 5 سطحی]: https://en.wikipedia.org/wiki/Intel_5-level_paging
### مثالی از ترجمه
بیایید مثالی بزنیم تا با جزئیات بفهمیم که روند ترجمه چگونه کار می‌کند:
![An example 4-level page hierarchy with each page table shown in physical memory](x86_64-page-table-translation.svg)
آدرس فیزیکی جدول صفحه سطح 4 که در حال حاضر فعال می‌باشد، و ریشه جدول صفحه سطح 4 است، در ثبات `CR3` ذخیره می‌شود. سپس هر ورودی جدول صفحه به قاب فیزیکی جدول سطح بعدی اشاره می‌کند. سپس ورودی جدول سطح 1 به قاب نگاشت شده اشاره می‌کند. توجه داشته باشید که تمام آدرس‌های موجود در جدول‌های صفحه فیزیکی هستند، به جای این‌که مجازی باشند، زیرا در غیر این‌صورت CPU نیاز به ترجمه آن آدرس‌ها نیز دارد (که این امر می‌تواند باعث بازگشت بی‌پایان شود).
سلسله مراتب جدول صفحه بالا، دو صفحه را نگاشت می‌کند (به رنگ آبی). از اندیس‌های جدول صفحه می‌توان نتیجه گرفت که آدرس‌های مجازی این دو صفحه `0x803FE7F000` و `0x803FE00000` است. بیایید ببینیم چه اتفاقی می‌افتد وقتی برنامه سعی می‌کند از آدرس `0x803FE7F5CE` بخواند. ابتدا آدرس را به باینری تبدیل می‌کنیم و اندیس‌های جدول صفحه و آفست صفحه را برای آدرس تعیین می‌کنیم:
![The sign extension bits are all 0, the level 4 index is 1, the level 3 index is 0, the level 2 index is 511, the level 1 index is 127, and the page offset is 0x5ce](x86_64-page-table-translation-addresses.png)
با استفاده از این اندیس‌ها، اکنون می‌توانیم سلسله مراتب جدول صفحه را برای تعیین قاب نگاشته شده برای آدرس دنبال کنیم:
- ما با خواندن آدرس جدول سطح 4 از ثبات `CR3` شروع می‌کنیم.
- اندیس سطح 4 برابر با 1 است، بنابراین ما به ورودی با اندیس 1 آن جدول نگاه می‌کنیم، که به ما می‌گوید جدول سطح 3 در آدرس 16KiB ذخیره شده است.
- ما جدول سطح 3 را از آن آدرس بارگیری می‌کنیم و ورودی با اندیس 0 را مشاهده می‌کنیم، که جدول سطح 2 در 24KiB را به ما نشان می‌دهد.
- اندیس سطح 2 برابر با 511 است، بنابراین ما برای یافتن آدرس جدول سطح 1 به آخرین ورودی آن صفحه نگاه می‌کنیم.
- از طریق ورودی با اندیس 127 جدول سطح 1، ما در نهایت متوجه می‌شویم که صفحه در قاب 12KiB، یا بصورت هگزادسیمال در 0x3000 نگاشت شده است.
- مرحله آخر افزودن آفست صفحه به آدرس قاب است تا آدرس فیزیکی 0x3000 + 0x5ce = 0x35ce بدست آید.
![The same example 4-level page hierarchy with 5 additional arrows: "Step 0" from the CR3 register to the level 4 table, "Step 1" from the level 4 entry to the level 3 table, "Step 2" from the level 3 entry to the level 2 table, "Step 3" from the level 2 entry to the level 1 table, and "Step 4" from the level 1 table to the mapped frames.](x86_64-page-table-translation-steps.svg)
مجوزهای صفحه در جدول سطح 1، مجوز "r" است، که به معنای فقط خواندن است. سخت‌افزار این مجوزها را اعمال می‌کند و اگر بخواهیم در آن صفحه بنویسیم یک استثنا را ایجاد می‌کند. مجوزها در صفحات سطح بالاتر مجوزهای احتمالی را در سطح پایین محدود می‌کنند، بنابراین اگر ورودی سطح 3 را فقط برای خواندن تنظیم کنیم، صفحه‌هایی که از این ورودی استفاده می‌کنند نیز قابل نوشتن نیستند، حتی اگر سطوح پایین‌تر مجوزهای خواندن/نوشتن را مشخص کرده باشند.
توجه به این نکته مهم است که اگرچه این مثال فقط از یک نمونه از هر جدول استفاده می‌کند، به طور معمول از هر سطح در هر فضای آدرس چندین نمونه وجود دارد. در حالت حداکثری، موارد زیر وجود دارد:
- یک جدول سطح 4،
- 512 جدول سطح 3 (زیرا جدول سطح 4 دارای 512 ورودی است)،
- 512 * 512 جدول سطح 2 (زیرا هر 512 جدولِ سطح 3 دارای 512 ورودی است)، و
- 512 * 512 * 512 جدول سطح 1 (512 ورودی برای هر جدول سطح 2).
### قالب جدول صفحه
جدول‌های صفحه در معماری x86_64 اساساً آرایه‌ای از 512 ورودی است. در سینتکس (کلمه: syntax) راست:
```rust
#[repr(align(4096))]
pub struct PageTable {
entries: [PageTableEntry; 512],
}
```
همان‌طور که با ویژگی `repr` نشان داده شده است، جدول‌های صفحه باید صفحه تراز شوند، یعنی در یک مرز 4KiB تراز شوند. این نیاز تضمین می‌کند که یک جدول صفحه همیشه یک صفحه کامل را پر می‌کند و به بهینه‌سازی اجازه می‌دهد که ورودی‌ها را بسیار جمع و جور کند.
هر ورودی 8 بایت (64 بیت) اندازه دارد و دارای قالب زیر است:
Bit(s) | Name | Meaning
------ | ---- | -------
0 | present | the page is currently in memory
1 | writable | it's allowed to write to this page
2 | user accessible | if not set, only kernel mode code can access this page
3 | write through caching | writes go directly to memory
4 | disable cache | no cache is used for this page
5 | accessed | the CPU sets this bit when this page is used
6 | dirty | the CPU sets this bit when a write to this page occurs
7 | huge page/null | must be 0 in P1 and P4, creates a 1GiB page in P3, creates a 2MiB page in P2
8 | global | page isn't flushed from caches on address space switch (PGE bit of CR4 register must be set)
9-11 | available | can be used freely by the OS
12-51 | physical address | the page aligned 52bit physical address of the frame or the next page table
52-62 | available | can be used freely by the OS
63 | no execute | forbid executing code on this page (the NXE bit in the EFER register must be set)
می‌بینیم که فقط بیت‌های 1251 برای ذخیره آدرس قاب فیزیکی استفاده می‌شود، بیت‌های باقی‌مانده به عنوان پرچم استفاده می‌شوند یا توسط سیستم‌عامل می‌توانند آزادانه استفاده شوند. این امکان وجود دارد زیرا ما همیشه به یک آدرس تراز شده 4096 بایت، یا به یک جدول صفحه تراز شده با صفحه یا به شروع یک قاب نگاشت شده، اشاره می‌کنیم. این بدان معناست که بیت‌های 011 همیشه صفر هستند، بنابراین دلیلی برای ذخیره این بیت‌ها وجود ندارد زیرا سخت‌افزار می‌تواند آن‌ها را قبل از استفاده از آدرس صفر کند. این مورد در بیت‌های 52-63 نیز صدق می‌کند، زیرا معماری x86_64 فقط از آدرس‌های فیزیکی 52 بیتی پشتیبانی می‌کند (همان‌طور که فقط از آدرس‌های مجازی 48 بیتی پشتیبانی می‌کند).
بیایید نگاهی دقیق‌تر به پرچم‌های موجود بیندازیم:
- پرچم `present` صفحات نگاشت شده را از صفحات نگاشته نشده متمایز می‌کند. وقتی حافظه اصلی پر شود می‌توان از آن برای تعویض موقت صفحات روی دیسک استفاده کرد. وقتی متعاقباً به صفحه دسترسی پیدا شد، یک استثنای ویژه به نام _page fault_ اتفاق می‌افتد که سیستم‌عامل می‌تواند با بارگیری مجدد صفحه از دست رفته از دیسک و سپس ادامه برنامه‌، به آن واکنش نشان دهد.
- پرچم‌های `writable` و `no execute` به ترتیب کنترل می‌کنند که آیا محتوای صفحه، «قابل نوشتن» یا «حاوی دستورالعمل‌های اجرایی بودن» هستند.
- پرچم های `accessed` و `dirty` به طور خودکار هنگام پردازش یا نوشتن روی صفحه توسط CPU تنظیم می‌شوند. این اطلاعات می‌تواند توسط سیستم‌عامل مورد استفاده قرار گیرد. به عنوان مثال برای تصمیم‌گیری در مورد تعویض صفحه‌ها یا تغییر محتوای صفحه از آخرین ذخیره روی دیسک.
- پرچم‌های `write through caching` و `disable cache` امکان کنترل حافظه پنهان برای هر صفحه را به صورت جداگانه فراهم می‌کند.
- پرچم `user accessible` یک صفحه را در دسترس کد فضای کاربر قرار می‌دهد، در غیر این‌صورت فقط وقتی CPU در حالت هسته است، قابل دسترسی است. از این ویژگی می‌تواند برای سریع‌تر کردن [فراخوانی‌های سیستم] با نگه داشتن نگاشت هسته در حین اجرای برنامه فضای کاربر مورد استفاده قرار گیرد. با این وجود، آسیب‌پذیری [Spectre] می‌تواند به برنامه‌های فضای کاربر اجازه دهد این صفحات را بخوانند.
- پرچم `global` به سخت‌افزار سیگنال می‌دهد که یک صفحه در تمام فضاهای آدرس موجود است و بنابراین نیازی به حذف شدن از حافظه پنهان ترجمه نیست (به بخش TLB زیر مراجعه کنید) در تعویض‌های فضای آدرس. این پرچم معمولاً همراه با یک پرچم پاک شده `user accessible` برای نگاشت کد هسته در تمام فضاهای آدرس استفاده می‌شود.
- پرچم `large page` با اجازه دادن به ورودی جدول‌های صفحه سطح 2 یا سطح 3، اجازه ایجاد صفحاتی با اندازه بزرگتر را می‌دهد تا مستقیماً به یک قاب نگاشت شده اشاره کنند. با استفاده از این بیت، اندازه صفحه با ضریب 512 افزایش می‌یابد برای هر یک از 2MiB = 512 * 4KiB ورودی‌های سطح 2 یا 1GiB = 512 * 2MiB برای ورودی‌های سطح 3. مزیت استفاده از صفحات بزرگتر این است که به خطوط حافظه پنهان ترجمه کمتر و جدول‌های صفحه کمتر نیاز است.
[فراخوانی‌های سیستم]: https://en.wikipedia.org/wiki/System_call
[Spectre]: https://en.wikipedia.org/wiki/Spectre_(security_vulnerability)
کریت `x86_64` انواع مختلفی را برای [جدول‌های صفحه] و [ورودی‌های] آن‌ها فراهم می‌کند، بنابراین نیازی نیست که خودمان این ساختارها را ایجاد کنیم.
[جدول‌های صفحه]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTable.html
[ورودی‌های]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTableEntry.html
### بافر ترجمه Lookaside
یک جدول صفحه 4 سطحی، ترجمه آدرس‌های مجازی را پُر هزینه‌ می‌کند، زیرا هر ترجمه به 4 دسترسی حافظه نیاز دارد. برای بهبود عملکرد، معماری x86_64 آخرین ترجمه‌ها را در _translation lookaside buffer_ یا به اختصار TLB ذخیره می‌کند. و این به ما اجازه می‌دهد تا از ترجمه کردن مجدد ترجمه‌هایی که در حافظه پنهان قرار دارند خودداری کنیم.
برخلاف سایر حافظه‌های پنهان پردازنده، TLB کاملاً شفاف نبوده و با تغییر محتوای جدول‌های صفحه، ترجمه‌ها را به‌روز و حذف نمی‌کند. این بدان معنی است که هسته هر زمان که جدول صفحه را تغییر می‌دهد باید TLB را به صورت دستی به‌روز کند. برای انجام این کار، یک دستورالعمل ویژه پردازنده وجود دارد به نام [`invlpg`] ("صفحه نامعتبر") که ترجمه برای صفحه مشخص شده را از TLB حذف می‌کند، بنابراین دوباره از جدول صفحه در دسترسی بعدی بارگیری می‌شود. TLB همچنین می‌تواند با بارگیری مجدد رجیستر `CR3`، که یک تعویض فضای آدرس را شبیه‌سازی می‌کند، کاملاً فلاش (کلمه: flush) شود. کریت `x86_64` توابع راست را برای هر دو نوع در [ماژول `tlb`] فراهم می‌کند.
[`invlpg`]: https://www.felixcloutier.com/x86/INVLPG.html
[ماژول `tlb`]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tlb/index.html
مهم است که به یاد داشته باشید که TLB را روی هر جدول صفحه فلاش کنید، زیرا در غیر این‌صورت پردازنده ممکن است از ترجمه قدیمی استفاده کند، که می‌تواند منجر به باگ‌های غیرقطعی شود که اشکال‌زدایی آن بسیار سخت است.
## پیاده‌سازی
چیزی که ما هنوز به آن اشاره نکردیم: **هسته ما از قبل با صفحه‌بندی اجرا می‌شود**. بوت‌لودری که در پست ["یک هسته مینیمال با Rust"] اضافه کردیم، قبلاً یک سلسله مراتب صفحه‌بندی 4 سطح را تنظیم کرده است که هر صفحه از هسته ما را در یک قاب فیزیکی نگاشت می‌کند. بوت‌لودر این کار را انجام می‌دهد زیرا صفحه‌بندی در حالت 64 بیتی در x86_64 اجباری است.
["یک هسته مینیمال با Rust"]: @/edition-2/posts/02-minimal-rust-kernel/index.fa.md#skht-dyskh-ymyj
این بدان معناست که هر آدرس حافظه‌ای که در هسته خود استفاده کردیم یک آدرس مجازی بود. دسترسی به بافر VGA در آدرس `0xb8000` فقط به این دلیل کار کرد که بوت‌لودر آن صفحه حافظه را نگاشت یکتا (ترجمه: identity mapped) کرد، یعنی صفحه مجازی `0xb8000` را با فریم فیزیکی `0xb8000` نگاشت کرده است.
صفحه‌بندی باعث می‌شود که هسته ما نسبتاً ایمن باشد، زیرا هر دسترسی به حافظه که از مرز خارج شود باعث ایجاد استثنای خطای صفحه، به جای نوشتن روی حافظه فیزیکی تصادفی می‌شود. بوت‌لودر حتی مجوزهای دسترسی صحیح را برای هر صفحه تنظیم کرده است، به این معنی که فقط صفحات حاوی کد قابل اجرا هستند و فقط صفحات داده قابل نوشتن هستند.
### خطاهای صفحه
بیایید سعی کنیم با دسترسی به برخی از حافظه‌های خارج از هسته، باعث ایجاد خطای صفحه شویم. ابتدا، یک کنترل‌کننده خطای صفحه ایجاد می‌کنیم و آن را در IDT ثبت می‌کنیم، به‌طوری که به جای یک [خطای دوگانه] یک استثنای خطای صفحه مشاهده می‌کنیم:
[خطای دوگانه]: @/edition-2/posts/06-double-faults/index.fa.md
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
[]
idt.page_fault.set_handler_fn(page_fault_handler); // new
idt
};
}
use x86_64::structures::idt::PageFaultErrorCode;
use crate::hlt_loop;
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
use x86_64::registers::control::Cr2;
println!("EXCEPTION: PAGE FAULT");
println!("Accessed Address: {:?}", Cr2::read());
println!("Error Code: {:?}", error_code);
println!("{:#?}", stack_frame);
hlt_loop();
}
```
ثبات [`CR2`] به‌طور خودکار توسط CPU روی خطای صفحه تنظیم می‌شود و حاوی آدرس مجازی قابل دسترسی است که باعث رخ دادن خطای صفحه شده است. ما برای خواندن و چاپ آن از تابع [`Cr2::read`] کریت ` x86_64` استفاده می‌کنیم. نوع [`PageFaultErrorCode`] اطلاعات بیشتری در مورد نوع دسترسی به حافظه‌ای که باعث خطای صفحه شده است، فراهم می کند، به عنوان مثال این امر به دلیل خواندن یا نوشتن بوده است. به همین دلیل ما آن را نیز چاپ می‌کنیم. بدون رفع خطای صفحه نمی‌توانیم به اجرا ادامه دهیم، بنابراین در انتها یک [hlt_loop] اضافه می‌کنیم.
[`CR2`]: https://en.wikipedia.org/wiki/Control_register#CR2
[`Cr2::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr2.html#method.read
[`PageFaultErrorCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html
[LLVM bug]: https://github.com/rust-lang/rust/issues/57270
[`hlt_loop`]: @/edition-2/posts/07-hardware-interrupts/index.md#the-hlt-instruction
اکنون می‌توانیم به برخی از حافظه‌های خارج از هسته خود دسترسی پیدا کنیم:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// new
let ptr = 0xdeadbeaf as *mut u8;
unsafe { *ptr = 42; }
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
blog_os::hlt_loop();
}
```
هنگامی که آن را اجرا می‌کنیم، می‌بینیم که کنترل‌کننده خطای صفحه ما صدا زده می‌شود:
![EXCEPTION: Page Fault, Accessed Address: VirtAddr(0xdeadbeaf), Error Code: CAUSED_BY_WRITE, InterruptStackFrame: {…}](qemu-page-fault.png)
ثبات `CR2` در واقع حاوی` 0xdeadbeaf` هست، آدرسی که سعی کردیم به آن دسترسی پیدا کنیم. کد خطا از طریق [`CAUSED_BY_WRITE`] به ما می‌گوید که خطا هنگام تلاش برای انجام یک عملیات نوشتن رخ داده است. حتی از طریق [بیت‌هایی که تنظیم _نشده‌اند_][`PageFaultErrorCode`] اطلاعات بیشتری به ما می‌دهد. به عنوان مثال، عدم تنظیم پرچم `PROTECTION_VIOLATION` به این معنی است که خطای صفحه رخ داده است زیرا صفحه هدف وجود ندارد.
[`CAUSED_BY_WRITE`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.CAUSED_BY_WRITE
می‌بینیم که اشاره‌گر دستورالعمل فعلی `0x2031b2` می‌باشد، بنابراین می‌دانیم که این آدرس به یک صفحه کد اشاره دارد. صفحات کد توسط بوت‌لودر بصورت فقط خواندنی نگاشت می‌شوند، بنابراین خواندن از این آدرس امکان‌پذیر است اما نوشتن باعث خطای صفحه می‌شود. می‌توانید این کار را با تغییر اشاره‌گر `0xdeadbeaf` به `0x2031b2` امتحان کنید:
```rust
// Note: The actual address might be different for you. Use the address that
// your page fault handler reports.
let ptr = 0x2031b2 as *mut u8;
// read from a code page
unsafe { let x = *ptr; }
println!("read worked");
// write to a code page
unsafe { *ptr = 42; }
println!("write worked");
```
با کامنت کردن خط آخر، می‌بینیم که دسترسی خواندن کار می‌کند، اما دسترسی نوشتن باعث خطای صفحه می‌شود:
![QEMU with output: "read worked, EXCEPTION: Page Fault, Accessed Address: VirtAddr(0x2031b2), Error Code: PROTECTION_VIOLATION | CAUSED_BY_WRITE, InterruptStackFrame: {…}"](qemu-page-fault-protection.png)
می‌بینیم که پیام _"read worked"_ چاپ شده است، که نشان می‌دهد عملیات خواندن هیچ خطایی ایجاد نکرده است. با این حال، به جای پیام _"write worked"_ خطای صفحه رخ می‌دهد. این بار پرچم [`PROTECTION_VIOLATION`] علاوه بر پرچم [`CAUSED_BY_WRITE`] تنظیم شده است، که نشان‌دهنده‌ وجود صفحه است، اما عملیات روی آن مجاز نیست. در این حالت نوشتن در صفحه مجاز نیست زیرا صفحات کد به صورت فقط خواندنی نگاشت می‌شوند.
[`PROTECTION_VIOLATION`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.PROTECTION_VIOLATION
### دسترسی به جدول‌های صفحه
بیایید سعی کنیم نگاهی به جدول‌های صفحه بیندازیم که نحوه نگاشت هسته را مشخص می‌کند:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
use x86_64::registers::control::Cr3;
let (level_4_page_table, _) = Cr3::read();
println!("Level 4 page table at: {:?}", level_4_page_table.start_address());
[] // test_main(), println(…), and hlt_loop()
}
```
تابع [`Cr3::read`] از ` x86_64` جدول صفحه سطح 4 که در حال حاضر فعال است را از ثبات `CR3` برمی‌گرداند. یک تاپل (کلمه: tuple) از نوع [`PhysFrame`] و [`Cr3Flags`] برمی‌گرداند. ما فقط به قاب علاقه‌مَندیم، بنابراین عنصر دوم تاپل را نادیده می‌گیریم.
[`Cr3::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3.html#method.read
[`PhysFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/frame/struct.PhysFrame.html
[`Cr3Flags`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3Flags.html
هنگامی که آن را اجرا می‌کنیم، خروجی زیر را مشاهده می‌کنیم:
```
Level 4 page table at: PhysAddr(0x1000)
```
بنابراین جدول صفحه سطح 4 که در حال حاضر فعال است در آدرس `0x100` در حافظه _فیزیکی_ ذخیره می‌شود، همان‌طور که توسط نوع بسته‌بندی [`PhysAddr`] نشان داده شده است. حال سوال این است: چگونه می‌توانیم از هسته خود به این جدول دسترسی پیدا کنیم؟
[`PhysAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.PhysAddr.html
دسترسی مستقیم به حافظه فیزیکی در هنگام فعال بودن صفحه‌بندی امکان پذیر نیست، زیرا برنامه‌ها به راحتی می‌توانند محافظت از حافظه (ترجمه: memory protection) را دور بزنند و در غیر این‌صورت به حافظه سایر برنامه‌ها دسترسی پیدا می‌کنند. بنابراین تنها راه دسترسی به جدول از طریق برخی از صفحه‌های مجازی است که به قاب فیزیکی در آدرس`0x1000` نگاشت شده. این مشکل ایجاد نگاشت برای قاب‌های جدول صفحه یک مشکل کلی است، زیرا هسته به طور مرتب به جدول‌های صفحه دسترسی دارد، به عنوان مثال هنگام اختصاص پشته برای یک نخِ (ترجمه: thread) جدید.
راه حل‌های این مشکل در پست بعدی با جزئیات توضیح داده شده است.
## خلاصه
این پست دو روش حفاظت از حافظه را ارائه می‌دهد: تقسیم‌بندی و صفحه‌بندی. در حالی که اولی از ناحیه حافظه با اندازه متغیر استفاده می‌کند و از تکه‌تکه شدن خارجی رنج می‌برد، دومی از صفحات با اندازه ثابت استفاده می‌کند و امکان کنترل دقیق‌تر مجوزهای دسترسی را فراهم می‌کند.
صفحه‌بندی اطلاعات نگاشت صفحات موجود در جدول‌های صفحه با یک یا چند سطح را ذخیره می‌کند. معماری x86_64 از جدول‌های صفحه با 4 سطح و اندازه صفحه 4KiB استفاده می‌کند. سخت‌افزار به‌طور خودکار جدول‌های صفحه را مرور می‌کند و ترجمه‌های حاصل را در TLB ذخیره می‌کند. این بافر به طور شفاف به‌روز نمی‌شود و باید به صورت دستی با تغییر جدول صفحه، فلاش شود.
ما فهمیدیم که هسته ما در حال حاضر در بالای صفحه‌بندی اجرا می‌شود و دسترسی غیرقانونی حافظه باعث استثناهای خطای صفحه می‌شود. ما سعی کردیم به جدول‌های صفحه فعلی دسترسی پیدا کنیم، اما قادر به انجام این کار نبودیم زیرا ثبات CR3 یک آدرس فیزیکی را ذخیره می‌کند که ما نمی‌توانیم مستقیماً از هسته به آن دسترسی داشته باشیم.
## بعدی چیست؟
در پست بعدی نحوه پیاده‌سازی پشتیبانی برای صفحه‌بندی در هسته توضیح داده شده است. که روش‌های مختلفی برای دسترسی به حافظه فیزیکی از هسته ارائه می‌دهد، که دسترسی به جدول‌های صفحه‌ای که هسته در آن اجرا می‌شود را امکان‌پذیر می‌کند. در این مرحله ما می‌توانیم توابع را برای ترجمه آدرس‌های مجازی به فیزیکی و ایجاد نگاشت‌های جدید در جدول‌های صفحه پیاده‌سازی کنیم.

View File

@@ -1,428 +0,0 @@
+++
title = "ページング入門"
weight = 8
path = "ja/paging-introduction"
date = 2019-01-14
[extra]
chapter = "Memory Management"
# Please update this when updating the translation
translation_based_on_commit = "3315bfe2f63571f5e6e924d58ed32afd8f39f892"
# GitHub usernames of the people that translated this post
translators = ["woodyZootopia", "JohnTitor"]
+++
この記事では**ページング**を紹介します。これは、私達のオペレーティングシステムにも使う、とても一般的なメモリ管理方式です。なぜメモリの分離が必要なのか、**セグメンテーション**がどういう仕組みなのか、**仮想メモリ**とは何なのか、ページングがいかにしてメモリ<ruby>断片化<rp> (</rp><rt>フラグメンテーション</rt><rp>) </rp></ruby>の問題を解決するのかを説明します。また、x86_64アーキテクチャにおける、マルチレベルページテーブルのレイアウトについても説明します。
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください(訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-08` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-08
<!-- toc -->
## メモリの保護
オペレーティングシステムの主な役割の一つに、プログラムを互いに分離するということがあります。例えば、ウェブブラウザがテキストエディタに干渉してはいけません。この目的を達成するために、オペレーティングシステムはハードウェアの機能を利用して、あるプロセスのメモリ領域に他のプロセスがアクセスできないようにします。ハードウェアやOSの実装によって、さまざまなアプローチがあります。
例として、ARM Cortex-Mプロセッサ組み込みシステムに使われていますのいくつかには、[メモリ保護ユニット][_Memory Protection Unit_] (Memory Protection Unit, MPU) が搭載されており、異なるアクセス権限例えば、アクセス不可、読み取り専用、読み書きなどを持つメモリ領域を少数例えば8個定義できます。MPUは、メモリアクセスのたびに、そのアドレスが正しいアクセス権限を持つ領域にあるかどうかを確認し、そうでなければ例外を投げます。プロセスを変更するごとにその領域とアクセス権限を変更すれば、オペレーティングシステムはそれぞれのプロセスが自身のメモリにのみアクセスすることを保証し、したがってプロセスを互いに分離できます。
[_Memory Protection Unit_]: https://developer.arm.com/docs/ddi0337/e/memory-protection-unit/about-the-mpu
x86においては、ハードウェアは2つの異なるメモリ保護の方法をサポートしています[セグメンテーション][segmentation]と[ページング][paging]です。
[segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
[paging]: https://en.wikipedia.org/wiki/Virtual_memory#Paged_virtual_memory
## セグメンテーション
セグメンテーションは1978年にはすでに導入されており、当初の目的はアドレス可能なメモリの量を増やすことでした。当時、CPUは16bitのアドレスしか使えなかったので、アドレス可能なメモリは64KiBに限られていました。この64KiBを超えてアクセスするために、セグメントレジスタが追加され、このそれぞれにオフセットアドレスを格納するようになりました。CPUがメモリにアクセスするとき、毎回このオフセットを自動的に加算するようにすることで、最大1MiBのメモリにアクセスできるようになりました。
メモリアクセスの種類によって、セグメントレジスタは自動的にCPUによって選ばれます。命令の<ruby>引き出し<rp> (</rp><rt>フェッチ</rt><rp>) </rp></ruby>にはコードセグメント`CS`が使用され、スタック操作(プッシュ・ポップ)にはスタックセグメント`SS`が使用されます。その他の命令では、データセグメント`DS`やエクストラセグメント`ES`が使用されます。その後、自由に使用できる`FS``GS`というセグメントレジスタも追加されました。
セグメンテーションの初期バージョンでは、セグメントレジスタは直接オフセットを格納しており、アクセス制御は行われていませんでした。これは後に[<ruby>プロテクトモード<rp> (</rp><rt>protected mode</rt><rp>) </rp></ruby>][_protected mode_]が導入されたことで変更されました。CPUがこのモードで動作している時、セグメント<ruby>記述子<rp> (</rp><rt>ディスクリプタ</rt><rp>) </rp></ruby>は<ruby>局所<rp> (</rp><rt>ローカル</rt><rp>) </rp></ruby>または<ruby>大域<rp> (</rp><rt>グローバル</rt><rp>) </rp>[**</ruby><ruby>記述子表<rp> (</rp><rt>ディスクリプタテーブル</rt><rp>) </rp></ruby>**][_descriptor table_]を格納します。これには(オフセットアドレスに加えて)セグメントのサイズとアクセス権限が格納されます。それぞれのプロセスに対し、メモリアクセスをプロセスのメモリ領域にのみ制限するような大域/局所記述子表をロードすることで、OSはプロセスを互いに隔離できます。
[_protected mode_]: https://en.wikipedia.org/wiki/X86_memory_segmentation#Protected_mode
[_descriptor table_]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
メモリアドレスを実際にアクセスされる前に変更するという点において、セグメンテーションは今やほぼすべての場所で使われている**仮想メモリ**というテクニックをすでに採用していたと言えます。
### 仮想メモリ
仮想メモリの背景にある考え方は、下層にある物理的なストレージデバイスからメモリアドレスを抽象化することです。ストレージデバイスに直接アクセスするのではなく、先に何らかの変換ステップが踏まれます。セグメンテーションの場合、この変換ステップとはアクティブなセグメントのオフセットアドレスを追加することです。例えば、オフセット`0x1111000`のセグメントにあるプログラムが`0x1234000`というメモリアドレスにアクセスすると、実際にアクセスされるアドレスは`0x2345000`になります。
この2種類のアドレスを区別するため、変換前のアドレスを **仮想(アドレス)** と、変換後のアドレスを **物理(アドレス)** と呼びます。この2種類のアドレスの重要な違いの一つは、物理アドレスは常に同じ一意なメモリ位置を指すということです。いっぽう仮想アドレスの指す場所は変換する関数に依存します。二つの異なる仮想アドレスが同じ物理アドレスを指すということは十分にありえます。また、変換関数が異なっていれば、同じ仮想アドレスが別の物理アドレスを示すということもありえます。
この特性が役立つ例として、同じプログラムを2つ並行して実行するという状況が挙げられます。
![Two virtual address spaces with address 0150, one translated to 100250, the other to 300450](segmentation-same-program-twice.svg)
同じプログラムを2つ実行していますが、別の変換関数が使われています。1つ目の<ruby>実体<rp> (</rp><rt>インスタンス</rt><rp>) </rp></ruby>ではセグメントのオフセットが100なので、0から150の仮想アドレスは100から250に変換されます。2つ目のインスタンスではオフセットが300なので、0から150の仮想アドレスが300から450に変換されます。これにより、プログラムが互いに干渉することなく同じコード、同じ仮想アドレスを使うことができます。
もう一つの利点は、プログラムが全く異なる仮想アドレスを使っていたとしても、物理メモリ上の任意の場所に置けるということです。したがって、OSはプログラムを再コンパイルすることなく利用可能なメモリをフルに活用できます。
### <ruby>断片化<rp> (</rp><rt>fragmentation</rt><rp>) </rp></ruby>
物理アドレスと仮想アドレスを分けることにより、セグメンテーションは非常に強力なものとなっています。しかし、これにより断片化という問題が発生します。例として、上で見たプログラムの3つ目を実行したいとしましょう
![Three virtual address spaces, but there is not enough continuous space for the third](segmentation-fragmentation.svg)
開放されているメモリは十分にあるにも関わらず、プログラムのインスタンスを重ねることなく物理メモリに対応づけることはできません。ここで必要なのは **連続した** メモリであり、開放されているメモリが小さな塊であっては使えないためです。
この断片化に対処する方法の一つは、実行を一時停止し、メモリの使用されている部分を寄せ集めて、変換関数を更新し、実行を再開することでしょう:
![Three virtual address spaces after defragmentation](segmentation-fragmentation-compacted.svg)
これで、プログラムの3つ目のインスタンスを開始するのに十分な連続したスペースができました。
このデフラグメンテーションという処理の欠点は、大量のメモリをコピーしなければならず、パフォーマンスを低下させてしまうことです。また、メモリが断片化しすぎる前に定期的に実行しないといけません。そうすると、プログラムが時々一時停止して反応がなくなるので、性能が予測不可能になってしまいます。
ほとんどのシステムでセグメンテーションが用いられなくなった理由の一つに、この断片化の問題があります。実際、x86の64ビットモードでは、セグメンテーションはもはやサポートされていません。代わりに **ページング** が使用されており、これにより断片化の問題は完全に回避されます。
## ページング
ページングの考え方は、仮想メモリ空間と物理メモリ空間の両方を、サイズの固定された小さなブロックに分割するというものです。仮想メモリ空間のブロックは **ページ** と呼ばれ、物理アドレス空間のブロックは **フレーム** と呼ばれます。各ページはフレームに独立してマッピングできるので、大きなメモリ領域を連続していない物理フレームに分割することが可能です。
この方法の利点は、上のメモリ空間断片化の状況をもう一度、セグメンテーションの代わりにページングを使って見てみれば明らかになります:
![With paging the third program instance can be split across many smaller physical areas](paging-fragmentation.svg)
この例では、ページサイズは50バイトなので、それぞれのメモリ領域が3つのページに分割されます。それぞれのページは個別にフレームに対応付けられるので、連続した仮想メモリ領域を非連続な物理フレームへと対応付けられるのです。これにより、デフラグを事前に実行することなく、3つ目のプログラムのインスタンスを開始できるようになります。
### 隠された断片化
少ない数の可変なサイズのメモリ領域を使っていたセグメンテーションと比べると、ページングでは大量の小さい固定サイズのメモリ領域を使います。すべてのフレームが同じ大きさなので、「小さすぎて使えないフレーム」などというものは存在せず、したがって断片化も起きません。
というより、**目に見える** 断片化は起きていない、という方が正しいでしょう。**<ruby>内部<rp> (</rp><rt>internal</rt><rp>) </rp></ruby>断片化**と呼ばれる、目に見えない断片化は依然として起こっています。内部断片化は、すべてのメモリ領域がページサイズの整数倍ぴったりにはならないために生じます。例えば、上の例でサイズが101のプログラムを考えてみてくださいこの場合でもサイズ50のページが3つ必要で、必要な量より49バイト多く占有します。これらの2種類の断片化を区別するため、セグメンテーションを使うときに起きる断片化は **<ruby>外部<rp> (</rp><rt>external</rt><rp>) </rp></ruby>断片化** と呼ばれます。
内部断片化が起こるのは残念なことですが、セグメンテーションで発生していた外部断片化よりも優れていることが多いです。確かにメモリ領域は無駄にしますが、デフラグメンテーションをする必要がなく、また断片化の量も予想できるからです(平均するとメモリ領域ごとにページの半分)。
### ページテーブル
最大で何百万ものページがそれぞれ独立にフレームに対応付けられることを見てきました。この対応付けの情報はどこかに保存されなければなりません。セグメンテーションでは、有効なメモリ領域ごとに個別のセグメントセレクタを使っていましたが、ページングではレジスタよりも遥かに多くのページが使われるので、これは不可能です。代わりにページングでは **ページテーブル** と呼ばれる<ruby>表<rp> (</rp><rt>テーブル</rt><rp>) </rp></ruby>構造を使って対応付の情報を保存します。
上の例では、ページテーブルは以下のようになります:
![Three page tables, one for each program instance. For instance 1 the mapping is 0->100, 50->150, 100->200. For instance 2 it is 0->300, 50->350, 100->400. For instance 3 it is 0->250, 50->450, 100->500.](paging-page-tables.svg)
それぞれのプログラムのインスタンスが独自のページテーブルを持っているのが分かります。現在有効なテーブルへのポインタは、特殊なCPUのレジスタに格納されます。`x86`においては、このレジスタは`CR3`と呼ばれています。それぞれのプログラムのインスタンスを実行する前に、正しいページテーブルを指すポインタをこのレジスタにロードするのはOSの役割です。
それぞれのメモリアクセスにおいて、CPUはテーブルへのポインタをレジスタから読み出し、テーブル内のアクセスされたページから対応するフレームを見つけ出します。これは完全にハードウェア内で行われ、実行しているプログラムからはこの動作は見えません。変換プロセスを高速化するために、多くのCPUアーキテクチャは前回の変換の結果を覚えておく専用のキャッシュを持っています。
アーキテクチャによっては、ページテーブルのエントリは"Flags"フィールドにあるアクセス権限のような属性も保持できます。上の例では、"r/w"フラグがあることにより、このページは読み書きのどちらも可能だということを示しています。
### <ruby>複数層<rp> (</rp><rt>Multilevel</rt><rp>) </rp></ruby>ページテーブル
上で見たシンプルなページテーブルは、アドレス空間が大きくなってくると問題が発生します:メモリが無駄になるのです。たとえば、`0`, `1_000_000`, `1_000_050` および `1_000_100`3ケタごとの区切りとして`_`を用いていますの4つの仮想ページを使うプログラムを考えてみましょう。
![Page 0 mapped to frame 0 and pages `1_000_000``1_000_150` mapped to frames 100250](single-level-page-table.svg)
このプログラムはたった4つしか物理フレームを必要としていないのに、テーブルには100万以上ものエントリが存在してしまっています。空のエントリを省略した場合、変換プロセスにおいてCPUが正しいエントリに直接ジャンプできなくなってしまうので、それはできませんたとえば、4つめのページが4つめのエントリを使っていることが保証されなくなってしまいます
この無駄になるメモリを減らせる、 **2層ページテーブル** を使ってみましょう。発想としては、それぞれのアドレス領域に異なるページテーブルを使うというものです。**レベル2** ページテーブルと呼ばれる追加のページテーブルは、アドレス領域とレベル1のページテーブルのあいだの対応を格納します。
これを理解するには、例を見るのが一番です。それぞれのレベル1テーブルは大きさ`10_000`の領域に対応するとします。すると、以下のテーブルが上のマッピングの例に対応するものとなります:
![Page 0 points to entry 0 of the level 2 page table, which points to the level 1 page table T1. The first entry of T1 points to frame 0, the other entries are empty. Pages `1_000_000``1_000_150` point to the 100th entry of the level 2 page table, which points to a different level 1 page table T2. The first three entries of T2 point to frames 100250, the other entries are empty.](multilevel-page-table.svg)
ページ0は最初の`10_000`バイト領域に入るので、レベル2ページテーブルの最初のエントリを使います。このエントリはT1というレベル1ページテーブルを指し、このページテーブルはページ`0`がフレーム`0`に対応すると指定します。
ページ`1_000_000`, `1_000_050`および`1_000_100`はすべて、`10_000`バイトの大きさの領域100個目に入るので、レベル2ページテーブルの100個目のエントリを使います。このエントリは、T2という別のレベル1テーブルを指しており、このレベル1テーブルはこれらの3つのページをフレーム`100`, `150`および`200`に対応させています。レベル1テーブルにおけるページアドレスには領域のオフセットは含まれていない、つまり例えば、ページ`1_000_050`のエントリは単に`50`である、ということに注意してください。
レベル2テーブルにはまだ100個の空のエントリがありますが、前の100万にくらべればこれはずっと少ないです。このように節約できる理由は、`10_000`から`10_000_000`の、対応付けのないメモリ領域のためのレベル1テーブルを作る必要がないためです。
2層ページテーブルの原理は、3、4、それ以上に多くの層に拡張できます。このとき、ページテーブルレジスタは最も高いレベルのテーブルを指し、そのテーブルは次に低いレベルのテーブルを指し、それはさらに低いレベルのものを、と続きます。そして、レベル1のテーブルは対応するフレームを指します。この原理は一般に **<ruby>複数層<rp> (</rp><rt>multilevel</rt><rp>) </rp></ruby>** ページテーブルや、 **<ruby>階層型<rp> (</rp><rt>hierarchical</rt><rp>) </rp></ruby>** ページテーブルと呼ばれます。
ページングと複数層ページテーブルの仕組みが理解できたので、x86_64アーキテクチャにおいてどのようにページングが実装されているのかについて見ていきましょう以下では、CPUは64ビットモードで動いているとします
## x86_64におけるページング
x86_64アーキテクチャは4層ページテーブルを使っており、ページサイズは4KiBです。それぞれのページテーブルは、層によらず512のエントリを持っています。それぞれのエントリの大きさは8バイトなので、それぞれのテーブルは512 * 8B = 4KiBであり、よってぴったり1ページに収まります。
(各)レベルのページテーブルインデックスは、仮想アドレスから直接求められます:
![Bits 012 are the page offset, bits 1221 the level 1 index, bits 2130 the level 2 index, bits 3039 the level 3 index, and bits 3948 the level 4 index](x86_64-table-indices-from-address.svg)
それぞれのテーブルインデックスは9ビットからなることがわかります。それぞれのテーブルに2^9 = 512エントリあることを考えるとこれは妥当です。最下位の12ビットは4KiBページ内でのオフセット2^12バイト = 4KiBです。48ビットから64ビットは捨てられます。つまり、x86_64は48ビットのアドレスにしか対応しておらず、そのため64ビットアーキテクチャなどとよく呼ばれるが実際には64ビットではないということです。
[5-level page table]: https://en.wikipedia.org/wiki/Intel_5-level_paging
48ビットから64ビットが捨てられるからといって、任意の値にしてよいということではありません。アドレスを一意にし、5層ページテーブルのような将来の拡張に備えるため、この範囲のすべてのビットは47ビットの値と同じにしないといけません。これは、[2の補数における符号拡張][sign extension in two's complement]によく似ているので、 **<ruby>符号<rp> (</rp><rt>sign</rt><rp>) </rp></ruby><ruby>拡張<rp> (</rp><rt>extension</rt><rp>) </rp></ruby>** とよばれています。アドレスが適切に符号拡張されていない場合、CPUは例外を投げます。
[sign extension in two's complement]: https://en.wikipedia.org/wiki/Two's_complement#Sign_extension
近年発売されたIntelのIce LakeというCPUは、[5層ページテーブル][5-level page tables]を使用することもでき、そうすると仮想アドレスが48ビットから57ビットまで延長されるということは書いておく価値があるでしょう。いまの段階で私たちのカーネルをこの特定のCPUに最適化する意味はないので、この記事では標準の4層ページテーブルのみを使うことにします。
[5-level page tables]: https://en.wikipedia.org/wiki/Intel_5-level_paging
### 変換の例
この変換の仕組みをより詳細に理解するために、例を挙げて見てみましょう。
![An example 4-level page hierarchy with each page table shown in physical memory](x86_64-page-table-translation.svg)
現在有効なレベル4ページテーブルの物理アドレス、つまりレベル4ページテーブルの「<ruby>根<rp> (</rp><rt>root</rt><rp>) </rp></ruby>」は`CR3`レジスタに格納されています。それぞれのページテーブルエントリは、次のレベルのテーブルの物理フレームを指しています。そして、レベル1のテーブルは対応するフレームを指しています。なお、ページテーブル内のアドレスは全て仮想ではなく物理アドレスであることに注意してください。さもなければ、CPUは変換プロセス中にそれらのアドレスも変換しなくてはならず、無限再帰に陥ってしまうかもしれないからです。
上のページテーブル階層構造は、最終的に青色の2つのページへの対応を行っています。ページテーブルのインデックスから、これらの2つのページの仮想アドレスは`0x803FE7F000``0x803FE00000`であると推論できます。プログラムがアドレス`0x803FE7F5CE`から読み込もうとしたときに何が起こるかを見てみましょう。まず、アドレスを2進数に変換し、アドレスのページテーブルインデックスとページオフセットが何であるかを決定します
![The sign extension bits are all 0, the level 4 index is 1, the level 3 index is 0, the level 2 index is 511, the level 1 index is 127, and the page offset is 0x5ce](x86_64-page-table-translation-addresses.png)
これらのインデックス情報をもとにページテーブル階層構造を移動して、このアドレスに対応するフレームを決定します:
- まず、`CR3`レジスタからレベル4テーブルのアドレスを読み出します。
- レベル4のインデックスは1なので、このテーブルの1つ目のインデックスを見ます。すると、レベル3テーブルはアドレス16KiBに格納されていると分かります。
- レベル3テーブルをそのアドレスから読み出し、インデックス0のエントリを見ると、レベル2テーブルは24KiBにあると教えてくれます。
- レベル2のインデックスは511なので、このページの最後のエントリを見て、レベル1テーブルのアドレスを見つけます。
- レベル1テーブルの127番目のエントリを読むことで、ついに対象のページは12KiB16進数では0x3000のフレームに対応づけられていると分かります。
- 最後のステップは、ページオフセットをフレームアドレスに足して、物理アドレスを得ることです。0x3000 + 0x5ce = 0x35ce
![The same example 4-level page hierarchy with 5 additional arrows: "Step 0" from the CR3 register to the level 4 table, "Step 1" from the level 4 entry to the level 3 table, "Step 2" from the level 3 entry to the level 2 table, "Step 3" from the level 2 entry to the level 1 table, and "Step 4" from the level 1 table to the mapped frames.](x86_64-page-table-translation-steps.svg)
レベル1テーブルにあるこのページの権限は`r`であり、これは読み込み専用という意味です。これらのような権限に対する侵害はハードウェアによって保護されており、このページに書き込もうとした場合は例外が投げられます。より高いレベルのページにおける権限は、下のレベルにおいて可能な権限を制限します。たとえばレベル3エントリを読み込み専用にした場合、下のレベルで読み書きを許可したとしても、このエントリを使うページはすべて書き込み不可になります。
この例ではそれぞれのテーブルの<ruby>実体<rp> (</rp><rt>インスタンス</rt><rp>) </rp></ruby>を1つずつしか使いませんでしたが、普通、それぞれのアドレス空間において、各レベルに対して複数のインスタンスが使われるということは知っておく価値があるでしょう。最大で
- 1個のレベル4テーブル
- 512個のレベル3テーブルレベル4テーブルには512エントリあるので
- 512 * 512個のレベル2テーブル512個のレベル3テーブルそれぞれに512エントリあるので
- 512 * 512 * 512個のレベル1テーブルそれぞれのレベル2テーブルに512エントリあるので
があります。
### ページテーブルの形式
x86_64アーキテクチャにおけるページテーブルは詰まるところ512個のエントリの配列です。Rustの構文では以下のようになります
```rust
#[repr(align(4096))]
pub struct PageTable {
entries: [PageTableEntry; 512],
}
```
`repr`属性で示されるように、ページテーブルはアラインされる必要があります。つまり4KiBごとの境界に揃えられる必要がある、ということです。この条件により、ページテーブルはつねにページひとつを完全に使うので、エントリをとてもコンパクトにできる最適化が可能になります。
それぞれのエントリは8バイト64ビットの大きさであり、以下の形式です
ビット | 名前 | 意味
------ | ---- | -------
0 | present | このページはメモリ内にある
1 | writable | このページへの書き込みは許可されている
2 | user accessible | 0の場合、カーネルモードのみこのページにアクセスできる
3 | write through caching | 書き込みはメモリに対して直接行われる
4 | disable cache | このページにキャッシュを使わない
5 | accessed | このページが使われているとき、CPUはこのビットを1にする
6 | dirty | このページへの書き込みが行われたとき、CPUはこのビットを1にする
7 | huge page/null | P1とP4においては0で、P3においては1GiBのページを、P2においては2MiBのページを作る
8 | global | キャッシュにあるこのページはアドレス空間変更の際に初期化されないCR4レジスタのPGEビットが1である必要がある
9-11 | available | OSが自由に使える
12-51 | physical address | ページ単位にアラインされた、フレームまたは次のページテーブルの52bit物理アドレス
52-62 | available | OSが自由に使える
63 | no execute | このページにおいてプログラムを実行することを禁じるEFERレジスタのNXEビットが1である必要がある
12-51ビットだけが物理フレームアドレスを格納するのに使われていて、残りのビットはフラグやオペレーティングシステムが自由に使うようになっていることがわかります。これが可能なのは、常に4096バイト単位のページに<ruby>揃え<rp> (</rp><rt>アライン</rt><rp>) </rp></ruby>られたアドレスページテーブルか、対応づけられたフレームの先頭を指しているからです。これは、0-11ビットは常にゼロであることを意味し、したがってこれらのビットを格納しておく必要はありません。アドレスを使用する前に、ハードウェアがそれらのビットをゼロとして追加してやれば良いからです。また、52-63ビットについても格納しておく必要はありません。なぜならx86_64アーキテクチャは52ビットの物理アドレスしかサポートしていないからです仮想アドレスを48ビットしかサポートしていないのと似ています
上のフラグについてより詳しく見てみましょう:
- `present`フラグは、対応付けられているページとそうでないページを区別します。このフラグは、メインメモリが一杯になったとき、ページを一時的にディスクにスワップしたいときに使うことができます。後でページがアクセスされたら、 **ページフォルト** という特別な例外が発生するので、オペレーティングシステムは不足しているページをディスクから読み出すことでこれに対応し、プログラムを再開します。
- `writable``no execute`フラグはそれぞれ、このページの中身が書き込み可能かと、実行可能な命令であるかを制御します。
- `accessed``dirty`フラグは、ページへの読み込みか書き込みが行われたときにCPUによって自動的に1にセットされます。この情報はオペレーティングシステムによって活用でき、例えば、どのページをスワップするかや、ページの中身が最後にディスクに保存されて以降に修正されたかを確認できます。
- `write through caching``disable cache`フラグで、キャッシュの制御をページごとに独立して行うことができます。
- `user accessible`フラグはページをユーザー空間のコードが利用できるようにします。このフラグが1になっていない場合、CPUがカーネルモードのときにのみアクセスできます。この機能は、ユーザ空間のプログラムが実行している間もカーネルの使用しているメモリを対応付けたままにしておくことで、[システムコール][system calls]を高速化するために使うことができます。しかし、[Spectre]脆弱性を使うと、この機能があるにもかかわらず、ユーザ空間プログラムがこれらのページを読むことができてしまいます。
- `global`フラグは、このページはすべてのアドレス空間で利用可能であり、よってアドレス空間の変更時に変換キャッシュTLBに関する下のセクションを読んでくださいから取り除く必要がないことをハードウェアに伝えます。このフラグはカーネルコードをすべてのアドレス空間に対応付けるため、一般的に`user accsessible`フラグと一緒に使われます。
- `huge page`フラグを使うと、レベル2か3のページが対応付けられたフレームを直接指すようにすることで、より大きいサイズのページを作ることができます。このビットが1のとき、ページの大きさは512倍になるので、レベル2のエントリの場合は2MiB = 512 * 4KiBに、レベル3のエントリの場合は1GiB = 512 * 2MiBにもなります。大きいページを使うことのメリットは、必要な変換キャッシュのラインの数やページテーブルの数が少なくなることです。
[system calls]: https://en.wikipedia.org/wiki/System_call
[Spectre]: https://en.wikipedia.org/wiki/Spectre_(security_vulnerability)
`x86_64`クレートが[ページテーブル][page tables]とその[エントリ][entries]のための型を提供してくれているので、これらの構造体を私達自身で作る必要はありません。
[page tables]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTable.html
[entries]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTableEntry.html
### トランスレーション・ルックアサイド・バッファ
4層ページテーブルを使うと、仮想アドレスを変換するたびに4回メモリアクセスを行わないといけないので、変換のコストは大きくなります。性能改善のために、x86_64アーキテクチャは、直前数回の変換内容を **トランスレーション・ルックアサイド・バッファ (translation lookaside buffer, TLB)** と呼ばれるところにキャッシュします。これにより、前の変換がまだキャッシュされているなら、変換をスキップできます。
他のCPUキャッシュと異なり、TLBは完全に透明ではなく、ページテーブルの内容が変わったときに変換内容を更新したり取り除いたりしてくれません訳注キャッシュが<ruby>透明<rp> (</rp><rt>transparent</rt><rp>) </rp></ruby>であるとは、利用者がキャッシュの存在を意識する必要がないという意味。つまり、カーネルがページテーブルを変更したときは、カーネル自らTLBを更新しないといけないということです。これを行うために、[`invlpg`]"invalidate page"、ページを無効化の意という特別なCPU命令があります。これは指定されたページの変換をTLBから取り除き、次のアクセスの際に再び読み込まれるようにします。また、TLBは`CR3`レジスタを再設定することでもflushできます。`CR3`レジスタの再設定は、アドレス空間が変更されたという状況を模擬するのです。`x86_64`クレートの[`tlb`モジュール][`tlb` module]が、両方のやり方のRust関数を提供しています。
<div class="note">
**訳注:** flushは「溜まった水をどっと流す」「トイレなどを水で洗い流す」という意味の言葉です。そのためコンピュータサイエンスにおいて「キャッシュなどに溜められていたデータを場合によっては適切な出力先に書き込みながら削除する」という意味を持つようになりました。ここではどこかに出力しているわけではないので、「初期化」と同じような意味と考えて差し支えないでしょう。
</div>
[`invlpg`]: https://www.felixcloutier.com/x86/INVLPG.html
[`tlb` module]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tlb/index.html
ページテーブルを修正したときは毎回TLBをflushしないといけないということはしっかりと覚えておいてください。これを行わないと、CPUは古い変換を使いつづけるかもしれず、これはデバッグの非常に難しい、予測不能なバグに繋がるかもしれないためです。
## 実装
ひとつ言っていなかったことがあります:**わたしたちのカーネルはすでにページングを使っています**。[Rustでつくる最小のカーネル]["A minimal Rust Kernel"]の記事で追加したブートローダは、すでに私たちのカーネルのすべてのページを物理フレームに対応付けるような4層ページ階層構造を設定しているのです。ブートローダがこれを行う理由は、x86_64の64ビットモードにおいてページングは必須となっているからです。
["A minimal Rust kernel"]: @/edition-2/posts/02-minimal-rust-kernel/index.ja.md#butoimeziwozuo-ru
つまり、私達がカーネルにおいて使ってきたすべてのメモリアドレスは、仮想アドレスだったということです。アドレス`0xb8000`にあるVGAバッファへのアクセスが上手くいっていたのは、ひとえにブートローダがこのメモリページを **恒等対応** させていた、つまり、仮想ページ`0xb8000`を物理フレーム`0xb8000`に対応させていたからです。
ページングにより、境界外メモリアクセスをしてもおかしな物理メモリに書き込むのではなくページフォルト例外を起こすようになっているため、私達のカーネルはすでに比較的安全になっていました。ブートローダはそれぞれのページに正しい権限を設定することさえしてくれるので、コードを含むページだけが実行可能であり、データを含むページだけが書き込み可能になっています。
### ページフォルト
カーネルの外のメモリにアクセスすることによって、ページフォルトを引き起こしてみましょう。まず、通常の[ダブルフォルト][double fault]ではなくページフォルト例外が得られるように、ページフォルト<ruby>処理関数<rp> (</rp><rt>ハンドラ</rt><rp>) </rp></ruby>を作ってIDTに追加しましょう
[double fault]: @/edition-2/posts/06-double-faults/index.ja.md
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
[]
idt.page_fault.set_handler_fn(page_fault_handler); // ここを追加
idt
};
}
use x86_64::structures::idt::PageFaultErrorCode;
use crate::hlt_loop;
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
use x86_64::registers::control::Cr2;
println!("EXCEPTION: PAGE FAULT");
println!("Accessed Address: {:?}", Cr2::read());
println!("Error Code: {:?}", error_code);
println!("{:#?}", stack_frame);
hlt_loop();
}
```
[`CR2`]レジスタは、ページフォルト時にCPUによって自動的に設定されており、その値はアクセスされページフォルトを引き起こした仮想アドレスになっています。`x86_64`クレートの[`Cr2::read`]関数を使ってこれを読み込み出力します。[`PageFaultErrorCode`]型は、ページフォルトを引き起こしたメモリアクセスの種類についてより詳しい情報を提供します(例えば、読み込みと書き込みのどちらによるものなのか、など)。そのため、これも出力します。ページフォルトを解決しない限り実行を継続することはできないので、最後は[`hlt_loop`]に入ります。
[`CR2`]: https://en.wikipedia.org/wiki/Control_register#CR2
[`Cr2::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr2.html#method.read
[`PageFaultErrorCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html
[LLVM bug]: https://github.com/rust-lang/rust/issues/57270
[`hlt_loop`]: @/edition-2/posts/07-hardware-interrupts/index.md#the-hlt-instruction
それではカーネル外のメモリにアクセスしてみましょう:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// ここを追加
let ptr = 0xdeadbeaf as *mut u8;
unsafe { *ptr = 42; }
// ここはこれまでと同じ
#[cfg(test)]
test_main();
println!("It did not crash!");
blog_os::hlt_loop();
}
```
これを実行すると、ページフォルトハンドラが呼びだされたのを見ることができます:
![EXCEPTION: Page Fault, Accessed Address: VirtAddr(0xdeadbeaf), Error Code: CAUSED_BY_WRITE, InterruptStackFrame: {…}](qemu-page-fault.png)
`CR2`レジスタは確かに私達がアクセスしようとしていたアドレスである`0xdeadbeaf`を格納しています。エラーコードが[`CAUSED_BY_WRITE`]なので、この<ruby>障害<rp> (</rp><rt>フォルト</rt><rp>) </rp></ruby>は<ruby>write<rp> (</rp><rt>書き込み</rt><rp>) </rp></ruby>操作の実行中に発生したのだと分かります。更に、[1にセットされていないビット][`PageFaultErrorCode`]からも情報を得ることができます。例えば、`PROTECTION_VIOLATION`フラグが1にセットされていないことから、ページフォルトは対象のページが存在しなかったために発生したのだと分かります。
[`CAUSED_BY_WRITE`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.CAUSED_BY_WRITE
ページフォルトを起こした時点での命令ポインタは`0x2031b2`であるので、このアドレスはコードページを指しているとわかります。コードページはブートローダによって読み込み専用に指定されているので、このアドレスからの読み込みは大丈夫ですが、このページへの書き込みはページフォルトを起こします。`0xdeadbeaf`へのポインタを`0x2031b2`に変更して、これを試してみましょう。
```rust
// 注意:実際のアドレスは個々人で違うかもしれません。
// あなたのページフォルトハンドラが報告した値を使ってください。
let ptr = 0x2031b2 as *mut u8;
// コードページから読み込む
unsafe { let x = *ptr; }
println!("read worked");
// コードページへと書き込む
unsafe { *ptr = 42; }
println!("write worked");
```
最後の2行をコメントアウトすると、読み込みアクセスだけになるので実行は成功しますが、そうしなかった場合ページフォルトが発生します
![QEMU with output: "read worked, EXCEPTION: Page Fault, Accessed Address: VirtAddr(0x2031b2), Error Code: PROTECTION_VIOLATION | CAUSED_BY_WRITE, InterruptStackFrame: {…}"](qemu-page-fault-protection.png)
"read worked"というメッセージが表示されますが、これは読み込み操作が何のエラーも発生させなかったことを示しています。しかし、"write worked"のメッセージではなく、ページフォルトが発生してしまいました。今回は[`CAUSED_BY_WRITE`]フラグに加えて[`PROTECTION_VIOLATION`]フラグがセットされています。これは、ページは存在していたものの、それに対する今回の操作が許可されていなかったということを示します。今回の場合、ページへの書き込みは、コードページが読み込み専用に指定されているため許可されていませんでした。
[`PROTECTION_VIOLATION`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.PROTECTION_VIOLATION
### ページテーブルへのアクセス
私達のカーネルがどのように(物理メモリに)対応づけられているのかを定義しているページテーブルを見てみましょう。
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
use x86_64::registers::control::Cr3;
let (level_4_page_table, _) = Cr3::read();
println!("Level 4 page table at: {:?}", level_4_page_table.start_address());
[] // test_main(), println(…), hlt_loop() などが続く
}
```
`x86_64`クレートの[`Cr3::read`]関数は、現在有効なレベル4ページテーブルを`CR3`レジスタから読みとって返します。この関数は[`PhysFrame`]型と[`Cr3Flags`]型のタプルを返します。私達はフレームにしか興味がないので、タプルの2つ目の要素は無視しました。
[`Cr3::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3.html#method.read
[`PhysFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/frame/struct.PhysFrame.html
[`Cr3Flags`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3Flags.html
これを実行すると、以下の出力を得ます:
```
Level 4 page table at: PhysAddr(0x1000)
```
というわけで、現在有効なレベル4ページテーブルは、[`PhysAddr`]ラッパ型が示すように、 **物理** メモリのアドレス`0x1000`に格納されています。ここで疑問が生まれます:このテーブルに私達のカーネルからアクセスするにはどうすればいいのでしょう?
[`PhysAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.PhysAddr.html
ページングが有効なとき、物理メモリに直接アクセスすることはできません。もしそれができたとしたら、プログラムは容易くメモリ保護を回避して他のプログラムのメモリにアクセスできてしまうだろうからです。ですので、テーブルにアクセスする唯一の方法は、アドレス`0x1000`の物理フレームに対応づけられているような仮想ページにアクセスすることです。ページテーブルの存在するフレームへの対応づけは(実用上も必要になる)一般的な問題です。なぜなら、例えば新しいスレッドのためにスタックを割り当てるときなど、カーネルは日常的にページテーブルにアクセスする必要があるためです。
この問題への解決策は次の記事で詳細に論じます。
## まとめ
この記事では2つのメモリ保護技術を紹介しましたセグメンテーションとページングです。前者は可変サイズのメモリ領域を使用するため外部断片化の問題が存在するのに対し、後者は固定サイズのページを使用するためアクセス権限に関して遥かに細やかな制御が可能となっていました。
ページングは、仮想メモリと物理メモリの対応情報を1層以上のページテーブルに格納します。x86_64アーキテクチャにおいては4層ページテーブルが使用され、ページサイズは4KiBです。ハードウェアは自動的にページテーブルを辿り、変換の結果をトランスレーション・ルックアサイド・バッファ (TLB) にキャッシュします。このバッファは自動的に更新されない「透明ではない」ので、ページテーブルの変更時には明示的にflushする必要があります。
私達のカーネルは既にページングによって動いており、不正なメモリアクセスはページフォルト例外を発生させるということを学びました。現在有効なページテーブルへとアクセスしたかったのですが、CR3レジスタに格納されている物理アドレスはカーネルから直接アクセスできないものであるため、それはできませんでした。
## 次は?
次の記事では、私達のカーネルをページングに対応させる方法について説明します。私達のカーネルから物理メモリにアクセスする幾つかの方法を示すので、これらを用いれば私達のカーネルが動作しているページテーブルにアクセスできます。そうすると、仮想アドレスを物理アドレスに変換する関数を実装でき、ページテーブルに新しい対応づけを作れるようになります。

View File

@@ -1,423 +0,0 @@
+++
title = "内存分页初探"
weight = 8
path = "zh-CN/paging-introduction"
date = 2019-01-14
[extra]
# Please update this when updating the translation
translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"
# GitHub usernames of the people that translated this post
translators = ["liuyuran"]
# GitHub usernames of the people that contributed to this translation
translation_contributors = ["JiangengDong"]
+++
本文主要讲解 _内存分页_ 机制一种我们将会应用到操作系统里的十分常见的内存模型。同时也会展开说明为何需要进行内存隔离、_分段机制_ 是如何运作的、_虚拟内存_ 是什么以及内存分页是如何解决内存碎片问题的同时也会对x86_64的多级页表布局进行探索。
<!-- more -->
这个系列的 blog 在[GitHub]上开放开发,如果你有任何问题,请在这里开一个 issue 来讨论。当然你也可以在[底部][at the bottom]留言。你可以在[`post-08`][post branch]找到这篇文章的完整源码。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-08
<!-- toc -->
## 内存保护
操作系统的主要任务之一就是隔离各个应用程序的执行环境,比如你的浏览器不应对你的文本编辑器造成影响,因此,操作系统会利用硬件级别的功能确保一个进程无法访问另一个进程的内存区域,但具体实现方式因硬件和操作系统实现而异。
比如一些 ARM Cortex-M 处理器(用于嵌入式系统)搭载了 [_内存保护单元_][_Memory Protection Unit_] (MPU)该单元允许你定义少量具有不同读写权限的内存区域。MPU可以确保每一次对内存的访问都需要具备对应的权限否则就会抛出异常。而操作系统则会在进程切换时确保当前进程仅能访问自己所持有的内存区域由此实现内存隔离。
[_Memory Protection Unit_]: https://developer.arm.com/docs/ddi0337/e/memory-protection-unit/about-the-mpu
在x86架构下硬件层次为内存保护提供了两种不同的途径[段][segmentation] 和 [页][paging]。
[segmentation]: https://en.wikipedia.org/wiki/X86_memory_segmentation
[paging]: https://en.wikipedia.org/wiki/Virtual_memory#Paged_virtual_memory
## 内存分段
内存分段技术出现于1978年初衷是用于扩展可用内存该技术的最初背景是当时的CPU仅使用16位地址而可使用的内存也只有64KiB。为了扩展可用内存用于存储偏移量的段寄存器这个概念应运而生CPU可以据此访问更多的内存因此可用内存被成功扩展到了1MiB。
CPU可根据内存访问方式自动确定段寄存器的定义对于指令获取操作使用代码段寄存器 `CS`;对于栈操作(入栈/出栈),使用栈段寄存器 `SS`;对于其他指令,则使用数据段寄存器 `DS` 或额外段寄存器 `ES`。另外还有两个后来添加的扩展段寄存器 `FS``GS`,可以随意使用。
在最初版本的内存分段中,段寄存器仅仅是直接包含了偏移量,并不包含任何权限控制,直到 [_保护模式_][_protected mode_] 这个概念的出现。当CPU进入此模式后段描述符会包含一个本地或全局的 [_描述符表_][_descriptor table_] 索引,它对应的数据包含了偏移量、段的大小和访问权限。通过加载各个进程所属的全局/本地描述符表,可以实现进程仅能访问属于自己的内存区域的效果,操作系统也由此实现了进程隔离。
[_protected mode_]: https://en.wikipedia.org/wiki/X86_memory_segmentation#Protected_mode
[_descriptor table_]: https://en.wikipedia.org/wiki/Global_Descriptor_Table
针对在判断权限前如何更正内存地址这个问题内存分段使用了一个如今已经高度普及的技术_虚拟内存_。
### 虚拟内存
所谓虚拟内存,就是将物理存储器地址抽象为一段完全独立的内存区域,在直接访问物理存储器之前,加入了一个地址转换的步骤。对于内存分页机制而言,地址转换就是在虚拟地址的基础上加入偏移量,如在偏移量为 `0x1111000` 的段中,虚拟地址 `0x1234000` 的对应的物理内存地址是 `0x2345000`
首先我们需要明确两个名词,执行地址转换步骤之前的地址叫做 _虚拟地址_,而转换后的地址叫做 _物理地址_,两者最显著的区别就是物理地址是全局唯一的,而两个虚拟地址理论上可能指向同一个物理地址。同样的,如果使用不同的地址偏移量,同一个虚拟地址可能会对应不同的物理地址。
最直观的例子就是同时执行两个相同的程序:
![Two virtual address spaces with address 0150, one translated to 100250, the other to 300450](segmentation-same-program-twice.svg)
如你所见,这就是两个相同程序的内存分配情况,两者具有不同的地址偏移量(即 _段基址_。第一个程序实例的段基址为100所以其虚拟地址范围0-150换算成物理地址就是100-250。第二个程序实例的段基址为300所以其虚拟地址范围0-150换算成物理地址就是300-450。所以该机制允许程序共用同一套代码逻辑使用同样的虚拟地址并且不会干扰到彼此。
该机制的另一个优点就是让程序不局限于特定的某一段物理内存,而是依赖另一套虚拟内存地址,从而让操作系统在不重编译程序的前提下使用全部的内存区域。
### 内存碎片
虚拟内存机制已经让内存分段机制十分强大,但也有碎片化的问题,请看,如果我们同时执行三个程序实例的话:
![Three virtual address spaces, but there is not enough continuous space for the third](segmentation-fragmentation.svg)
在不能重叠使用的前提下,我们完全找不到足够的地方来容纳第三个程序,因为剩余的连续空间已经不够了。此时的问题在于,我们需要使用 _连续_ 的内存区域,不要将那些中间的空白部分白白浪费掉。
比较合适的办法就是暂停程序运行,将内存块移动到一个连续区间内,更新段基址信息,然后恢复程序运行:
![Three virtual address spaces after defragmentation](segmentation-fragmentation-compacted.svg)
这样我们就有足够的内存空间来运行第三个程序实例了。
但这样做也有一些问题,内存整理程序往往需要拷贝一段比较大的内存,这会很大程度上影响性能,但是又必须在碎片问题变得过于严重前完成这个操作。同时由于其消耗时间的不可预测性,程序很可能会随机挂起,甚至在用户视角下失去响应。
这也是大多数系统放弃内存分段技术的原因之一事实上该技术已经被x86平台的64位模式所抛弃因为 _内存分页技术_ 已经完全解决了碎片化问题。
## 内存分页
内存分页的思想依然是使用虚拟地址映射物理地址,但是其分配单位变成了固定长度的较小的内存区域。这些虚拟内存块被称为 __,而其对应的物理内存则被称为 _页帧_,每一页都可以映射到一个对应的页帧中。这也就意味着我们可以将程序所使用的一大块内存区域打散到所有物理内存中,而不必分配一块连续的区域。
其优势就在于,如果我们遇到上文中提到的内存碎片问题时,内存分页技术会这样解决它:
![With paging the third program instance can be split across many smaller physical areas](paging-fragmentation.svg)
例如我们将页的单位设置为50字节也就是说我们的每一个程序实例所使用的内存都被分割为三页。每一页都可以独立映射到一个页帧中因此连续的虚拟内存并不一定需要对应连续的物理内存区域因此也就无需进行内存碎片整理了。
### 潜在碎片
对比内存分段,内存分页选择用较多的较小且固定长度的内存区域代替较少的较大且长度不固定的内存区域。正因为如此,不会有页帧因为长度过小而产生内存碎片。
然而这只是 _表面上如此_,实际上依然存在着名为 _内部碎片_ 的隐蔽内存碎片造成内部碎片的原因是并非每个内存区域都是分页单位的整数倍。比如一个程序需要101字节的内存但它依然需要分配3个长度为50字节的页最终造成了49字节的内存浪费区别于内存分段造成的内存碎片这种情况被称为 _内部碎片_
内部碎片虽然也很可恶,但是无论如何也比内存分段造成的内存碎片要好得多,尽管其依然会浪费内存空间,但是无需碎片整理,且碎片数量是可预测的(每一个虚拟内存空间平均会造成半个页帧的内存浪费)。
### 页表
我们应当预见到,在操作系统开始运行后,会存在数以百万计的页-页帧映射关系,这些映射关系需要存储在某个地方。分段技术可以为每个活动的内存区域都指定一个段寄存器,但是分页技术不行,因为其使用到的页的数量实在是太多了,远多于寄存器数量,所以分页技术采用了一种叫做 _页表_ 的结构来存储映射信息。
以上面的应用场合为例,页表看起来是这样子的:
![Three page tables, one for each program instance. For instance 1 the mapping is 0->100, 50->150, 100->200. For instance 2 it is 0->300, 50->350, 100->400. For instance 3 it is 0->250, 50->450, 100->500.](paging-page-tables.svg)
我们可以看到每个程序实例都有其专有的页表但当前正在活跃的页表指针会被存储到特定的CPU寄存器中`x86` 架构中,该寄存器被称为 `CR3`。操作系统的任务之一,就是在程序运行前,把当前所使用的页表指针推进对应的寄存器中。
每次内存访问CPU都会从寄存器获取页表指针并从页表中获取虚拟地址所对应的页帧这一步操作完全由硬件完成对于程序而言是完全透明的。为了加快地址转换的速度许多CPU架构都加入了一个能够存储最后一次地址转换相关信息的特殊缓存。
根据架构实现的不同,页表也可以在 flags 字段存储一些额外的属性,如访问权限之类。在上面的场景下。 "r/w" 这个 flag 可以使该页同时能够读和写。
### 多级页表
上文中的简单页表在较大的地址空间下会有个问题太浪费内存了。打个比方一个程序需要使用4个虚拟内存页 `0``1_000_000``1_000_050``1_000_100`(假设以 `_` 为千位分隔符):
![Page 0 mapped to frame 0 and pages `1_000_000``1_000_150` mapped to frames 100250](single-level-page-table.svg)
尽管它仅仅会使用4个页帧但是页表中有百万级别的映射条目而我们还不能释放那些空白的条目因为这会对地址转换造成很大的风险比如可能无法保证4号页依然对应4号页帧
我们可以使用 **两级页表** 来避免内存浪费,其基本思路就是对不同的地址区域使用不同的页表。地址区域和一级页表的映射关系被存储在一个被称为 _二级页表_ 的额外表格中。
举个例子,我们先假设每个一级页表映射 `10_000` 字节的内存空间,在上文所述的应用场合下,此时的页表结构看上去是这样的:
![Page 0 points to entry 0 of the level 2 page table, which points to the level 1 page table T1. The first entry of T1 points to frame 0, the other entries are empty. Pages `1_000_000``1_000_150` point to the 100th entry of the level 2 page table, which points to a different level 1 page table T2. The first three entries of T2 point to frames 100250, the other entries are empty.](multilevel-page-table.svg)
`0` 位于第一个 `10_000` 字节的内存区域内,位于内存区域 `0` 内,对应一级页表 `T1`,所以它所在的内存位置也可以被表述为 `页 0 帧 0`.
`1_000_000``1_000_050``1_000_100` 均可以映射到第100个 `10_000` 字节的内存区域内,所以位于内存区域 `1_000_100` 中,该内存区域指向一级页表 T2。但这三个页分别对应该一级页表 T2 中的页帧 `100``150``200`,因为一级页表中是不存储内存区域偏移量的。
在这个场合中二级页表中还是出现了100个被浪费的位置不过无论如何也比之前数以百万计的浪费好多了因为我们没有额外创建指向 `10_000``1_000_000` 这段内存区域的一级页表。
同理,两级页表的原理可以扩展到三级、四级甚至更多的级数。通常而言,可以让页表寄存器指向最高级数的表,然后一层一层向下寻址,直到抵达一级页表,获取页帧地址。这种技术就叫做 _多级__多层_ 页表。
那么现在我们已经明白了内存分页和多级页表机制的工作原理,下面我们会探索一下在 x86_64 平台下内存分页机制是如何实现的假设CPU运行在64位模式下
## x86_64中的分页
x86_64 平台使用4级页表页大小为4KiB无论层级每个页表均具有512个条目每个条目占用8字节所以每个页表固定占用 512 * 8B = 4KiB正好占满一个内存页。
每一级的页表索引号都可以通过虚拟地址推导出来:
![Bits 012 are the page offset, bits 1221 the level 1 index, bits 2130 the level 2 index, bits 3039 the level 3 index, and bits 3948 the level 4 index](x86_64-table-indices-from-address.svg)
我们可以看到每个表索引号占据9个字节这当然是有道理的每个表都有 2^9 = 512 个条目低12位用来表示内存页的偏移量2^12 bytes = 4KiB而上文提到页大小为4KiB。第48-64位毫无用处这也就意味着 x86_64 并非真正的64位因为它实际上支持48位地址。
[5-level page table]: https://en.wikipedia.org/wiki/Intel_5-level_paging
尽管48-64位毫无用处但依然不被允许随意赋值而是必须将其设置为与47位相同的值以保证地址唯一性由此留出未来对此进行扩展的可能性如实现5级页表。该技术被称为 _符号扩展_,理由是它与 [二进制补码][sign extension in two's complement] 机制真的太相似了。当地址不符合该机制定义的规则时CPU会抛出异常。
[sign extension in two's complement]: https://en.wikipedia.org/wiki/Two's_complement#Sign_extension
值得注意的是英特尔最近发布了一款代号是冰湖的CPU它的新功能之一就是可选支持能够将虚拟地址从48位扩展到57位的 [5级页表][5-level page tables]。但是针对一款特定的CPU做优化在现阶段并没有多少意义所以本文仅会涉及标准的4级页表。
[5-level page tables]: https://en.wikipedia.org/wiki/Intel_5-level_paging
### 地址转换范例
请看下图,这就是一个典型的地址转换过程的范例:
![An example 4-level page hierarchy with each page table shown in physical memory](x86_64-page-table-translation.svg)
`CR3` 寄存器中存储着指向4级页表的物理地址而在每一级的页表除一级页表外都存在着指向下一级页表的指针1级页表则存放着直接指向页帧地址的指针。注意这里的指针都是指页表的物理地址而非虚拟地址否则CPU会因为需要进行额外的地址转换而陷入无限递归中。
最终,寻址结果是上图中的两个蓝色区域,根据页表查询结果,它们的虚拟地址分别是 `0x803FE7F000``0x803FE00000`,那么让我们看一看当程序尝试访问内存地址 `0x803FE7F5CE` 时会发生什么事情。首先我们需要把地址转换为二进制,然后确定该地址所对应的页表索引和页偏移量:
![The sign extension bits are all 0, the level 4 index is 1, the level 3 index is 0, the level 2 index is 511, the level 1 index is 127, and the page offset is 0x5ce](x86_64-page-table-translation-addresses.png)
通过这些索引,我们就可以通过依次查询多级页表来定位最终要指向的页帧:
- 首先,我们需要从 `CR3` 寄存器中读出4级页表的物理地址。
- 4级页表的索引号是1所以我们可以看到3级页表的地址是16KiB。
- 载入3级页表根据索引号0确定2级页表的地址是24KiB。
- 载入2级页表根据索引号511确定1级页表的地址是32KiB。
- 载入1级页表根据索引号127确定该地址所对应的页帧地址为12KiB使用Hex表达可写作 0x3000。
- 最终步骤就是将最后的页偏移量拼接到页帧地址上,即可得到物理地址,即 0x3000 + 0x5ce = 0x35ce。
![The same example 4-level page hierarchy with 5 additional arrows: "Step 0" from the CR3 register to the level 4 table, "Step 1" from the level 4 entry to the level 3 table, "Step 2" from the level 3 entry to the level 2 table, "Step 3" from the level 2 entry to the level 1 table, and "Step 4" from the level 1 table to the mapped frames.](x86_64-page-table-translation-steps.svg)
由上图可知,该页帧在一级页表中的权限被标记为 `r`即只读硬件层面已经确保当我们试图写入数据的时候会抛出异常。较高级别的页表的权限设定会覆盖较低级别的页表如3级页表中设定为只读的区域其所关联的所有下级页表对应的内存区域均会被认为是只读低级别的页表本身的设定会被忽略。
注意示例图片中为了简化显示看起来每个页表都只有一个条目但实际上4级以下的页表每一层都可能存在多个实例其数量上限如下
- 1个4级页表
- 512个3级页表因为4级页表可以有512个条目
- 512*512个2级页表因为每个3级页表可以有512个条目
- 512*512*512个1级页表因为每个2级页表可以有512个条目
### 页表格式
在 x86_64 平台下页表是一个具有512个条目的数组于Rust而言就是这样
```rust
#[repr(align(4096))]
pub struct PageTable {
entries: [PageTableEntry; 512],
}
```
`repr` 属性定义了内存页的大小这里将其设定为了4KiB该设置确保了页表总是能填满一整个内存页并允许编译器进行一些优化使其存储方式更加紧凑。
每个页表条目长度都是8字节64比特其内部结构如下
| Bit(s) | 名字 | 含义 |
| ------ | --------------------- | ----------------------------------------------------------------------------- |
| 0 | present | 该页目前在内存中 |
| 1 | writable | 该页可写 |
| 2 | user accessible | 如果没有设定,仅内核代码可以访问该页 |
| 3 | write through caching | 写操作直接应用到内存 |
| 4 | disable cache | 对该页禁用缓存 |
| 5 | accessed | 当该页正在被使用时CPU设置该比特的值 |
| 6 | dirty | 当该页正在被写入时CPU设置该比特的值 |
| 7 | huge page/null | 在P1和P4状态时必须为0在P3时创建一个1GiB的内存页在P2时创建一个2MiB的内存页 |
| 8 | global | 当地址空间切换时该页尚未应用更新CR4寄存器中的PGE比特位必须一同被设置 |
| 9-11 | available | 可被操作系统自由使用 |
| 12-51 | physical address | 经过52比特对齐过的页帧地址或下一级的页表地址 |
| 52-62 | available | 可被操作系统自由使用 |
| 63 | no execute | 禁止在该页中运行代码EFER寄存器中的NXE比特位必须一同被设置 |
我们可以看到仅1251位会用于存储页帧地址或页表地址其余比特都用于存储标志位或由操作系统自由使用。
其原因就是该地址总是指向一个4096比特对齐的地址、页表或者页帧的起始地址。
这也就意味着0-11位始终为0没有必要存储这些东西硬件层面在使用该地址之前也会将这12位比特设置为052-63位同理因为x86_64平台仅支持52位物理地址类似于上文中提到的仅支持48位虚拟地址的原因
进一步说明一下可用的标志位:
- `present` 标志位并非是指未映射的页,而是指其对应的内存页由于物理内存已满而被交换到硬盘中,如果该页在换出之后再度被访问,则会抛出 _page fault_ 异常,此时操作系统应该将此页重新载入物理内存以继续执行程序。
- `writable``no execute` 标志位分别控制该页是否可写,以及是否包含可执行指令。
- `accessed``dirty` 标志位由CPU在读写该页时自动设置该状态信息可用于辅助操作系统的内存控制如判断哪些页可以换出以及换出到硬盘后页里的内容是否已被修改。
- `write through caching``disable cache` 标志位可以单独控制每一个页对应的缓存。
- `user accessible` 标志位决定了页中是否包含用户态的代码否则它仅当CPU处于核心态时可访问。该特性可用于在用户态程序运行时保持内核代码映射以加速[系统调用][system calls]。然而,[Spectre] 漏洞会允许用户态程序读取到此类页的数据。
- `global` 标志位决定了该页是否会在所有地址空间都存在即使切换地址空间也不会从地址转换缓存参见下文中关于TLB的章节中被移除。一般和 `user accessible` 标志位共同使用,在所有地址空间映射内核代码。
- `huge page` 标志位允许2级页表或3级页表直接指向页帧来分配一块更大的内存空间该标志位被启用后页大小会增加512倍。就结果而言对于2级页表的条目其会直接指向一个 2MiB = 512 * 4KiB 大小的大型页帧而对于3级页表的条目就会直接指向一个 1GiB = 512 * 2MiB 大小的巨型页帧。通常而言,这个功能会用于节省地址转换缓存的空间,以及降低逐层查找页表的耗时。
[system calls]: https://en.wikipedia.org/wiki/System_call
[Spectre]: https://en.wikipedia.org/wiki/Spectre_(security_vulnerability)
`x86_64` crate 为我们提供了 [page tables] 的结构封装,以及其内部条目 [entries],所以我们无需自己实现具体的结构。
[page tables]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTable.html
[entries]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page_table/struct.PageTableEntry.html
### 地址转换后备缓冲区TLB
显而易见4级页表使地址转换过程变得有点慢每次转换都需要进行4次内存访问。为了改善这个问题x86_64平台将最后几次转换结果放在所谓的 _地址转换后备缓冲区_TLB这样对同样地址的连续重复转换就可以直接返回缓存中存储的结果。
不同于CPU缓存TLB并非是完全对外透明的它在页表变化时并不会自动更新或删除被缓存的结果。这也就是说内核需要在页表发生变化时自己来处理TLB的更新。针对这个需要CPU也提供了一个用于从TLB删除特定页的缓存的指令 [`invlpg`] “invalidate page”调用该指令之后下次访问该页就会重新生成缓存。不过还有一个更彻底的办法通过手动写入 `CR3` 寄存器可以制造出模拟地址空间切换的效果TLB也会被完全刷新。`x86_64` crate 中的 [`tlb` module] 提供了上面的两种手段,并封装了对应的函数。
[`invlpg`]: https://www.felixcloutier.com/x86/INVLPG.html
[`tlb` module]: https://docs.rs/x86_64/0.14.2/x86_64/instructions/tlb/index.html
请注意在修改页表之后同步修改TLB是十分十分重要的事情不然CPU可能会返回一个错误的物理地址因为这种原因造成的bug是非常难以追踪和调试的。
## 具体实现
有件事我们还没有提过:**我们的内核已经是在页上运行的**。在前文 ["最小内核"]["A minimal Rust Kernel"] 中我们添加的bootloader已经搭建了一个4级页表结构并将内核中使用的每个页都映射到了物理页帧上其原因就是在64位的 x86_64 平台下分页是被强制使用的。
["A minimal Rust kernel"]: @/edition-2/posts/02-minimal-rust-kernel/index.md#creating-a-bootimage
这也就是说我们在内核中所使用的每一个内存地址其实都是虚拟地址VGA缓冲区是唯一的例外因为bootloader为这个地址使用了 _一致映射_,令其直接指向地址 `0xb8000`。所谓一致映射,就是能将虚拟页 `0xb8000` 直接映射到物理页帧 `0xb8000`
使用分页技术后,我们的内核在某种意义上已经十分安全了,因为越界的内存访问会导致 page fault 异常而不是访问到一个随机物理地址。bootloader已经为每一个页都设置了正确的权限比如仅代码页具有执行权限、仅数据页具有写权限。
### Page Faults
那么我们来通过内存越界访问手动触发一次 page fault首先我们先写一个错误处理函数并注册到IDT中这样我们就可以正常接收到这个异常而非 [double fault] 了:
[double fault]: @/edition-2/posts/06-double-faults/index.md
```rust
// in src/interrupts.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
[]
idt.page_fault.set_handler_fn(page_fault_handler); // new
idt
};
}
use x86_64::structures::idt::PageFaultErrorCode;
use crate::hlt_loop;
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
use x86_64::registers::control::Cr2;
println!("EXCEPTION: PAGE FAULT");
println!("Accessed Address: {:?}", Cr2::read());
println!("Error Code: {:?}", error_code);
println!("{:#?}", stack_frame);
hlt_loop();
}
```
[`CR2`] 寄存器会在 page fault 发生时被CPU自动写入导致异常的虚拟地址我们可以用 `x86_64` crate 提供的 [`Cr2::read`] 函数来读取并打印该寄存器。[`PageFaultErrorCode`] 类型为我们提供了内存访问型异常的具体信息,比如究竟是因为读取还是写入操作,我们同样将其打印出来。并且不要忘记,在显式结束异常处理前,程序是不会恢复运行的,所以要在最后调用 [`hlt_loop`] 函数。
[`CR2`]: https://en.wikipedia.org/wiki/Control_register#CR2
[`Cr2::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr2.html#method.read
[`PageFaultErrorCode`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html
[LLVM bug]: https://github.com/rust-lang/rust/issues/57270
[`hlt_loop`]: @/edition-2/posts/07-hardware-interrupts/index.md#the-hlt-instruction
那么可以开始触发内存越界访问了:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// new
let ptr = 0xdeadbeaf as *mut u8;
unsafe { *ptr = 42; }
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
blog_os::hlt_loop();
}
```
启动执行后我们可以看到page fault 的处理函数被触发了:
![EXCEPTION: Page Fault, Accessed Address: VirtAddr(0xdeadbeaf), Error Code: CAUSED_BY_WRITE, InterruptStackFrame: {…}](qemu-page-fault.png)
`CR2` 确实保存了导致异常的虚拟地址 `0xdeadbeaf`,而错误码 [`CAUSED_BY_WRITE`] 也说明了导致异常的操作是写入。甚至于可以通过 [未设置的比特位][`PageFaultErrorCode`] 看出更多的信息,例如 `PROTECTION_VIOLATION` 未被设置说明目标页根本就不存在。
[`CAUSED_BY_WRITE`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.CAUSED_BY_WRITE
并且我们可以看到当前指令指针是 `0x2031b2`根据上文的知识我们知道它应该属于一个代码页。而代码页被bootloader设定为只读权限所以读取是正常的但写入就会触发 page fault 异常。比如你可以试着将上面代码中的 `0xdeadbeaf` 换成 `0x2031b2`
```rust
// Note: The actual address might be different for you. Use the address that
// your page fault handler reports.
let ptr = 0x2031b2 as *mut u8;
// read from a code page
unsafe { let x = *ptr; }
println!("read worked");
// write to a code page
unsafe { *ptr = 42; }
println!("write worked");
```
执行后,我们可以看到读取操作成功了,但写入操作抛出了 page fault 异常:
![QEMU with output: "read worked, EXCEPTION: Page Fault, Accessed Address: VirtAddr(0x2031b2), Error Code: PROTECTION_VIOLATION | CAUSED_BY_WRITE, InterruptStackFrame: {…}"](qemu-page-fault-protection.png)
我们可以看到 _"read worked"_ 这条日志,说明读操作没有出问题,而 _"write worked"_ 这条日志则没有被打印,起而代之的是一个异常日志。这一次 [`PROTECTION_VIOLATION`] 标志位的 [`CAUSED_BY_WRITE`] 比特位被设置,说明异常正是被非法写入操作引发的,因为我们之前为该页设置了只读权限。
[`PROTECTION_VIOLATION`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.PageFaultErrorCode.html#associatedconstant.PROTECTION_VIOLATION
### 访问页表
那么我们来看看内核中页表的存储方式:
```rust
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
use x86_64::registers::control::Cr3;
let (level_4_page_table, _) = Cr3::read();
println!("Level 4 page table at: {:?}", level_4_page_table.start_address());
[] // test_main(), println(…), and hlt_loop()
}
```
`x86_64` crate 中的 [`Cr3::read`] 函数可以返回 `CR3` 寄存器中的当前使用的4级页表它返回的是 [`PhysFrame`] 和 [`Cr3Flags`] 两个类型组成的元组结构。不过此时我们只关心页帧信息,所以第二个元素暂且不管。
[`Cr3::read`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3.html#method.read
[`PhysFrame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/frame/struct.PhysFrame.html
[`Cr3Flags`]: https://docs.rs/x86_64/0.14.2/x86_64/registers/control/struct.Cr3Flags.html
然后我们会看到如下输出:
```
Level 4 page table at: PhysAddr(0x1000)
```
所以当前的4级页表存储在 _物理地址_ `0x1000` 处,而且地址的外层数据结构是 [`PhysAddr`],那么问题来了:我们如何在内核中直接访问这个页表?
[`PhysAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.PhysAddr.html
当分页功能启用时,直接访问物理内存是被禁止的,否则程序就可以很轻易的侵入其他程序的内存,所以唯一的途径就是通过某些手段构建一个指向 `0x1000` 的虚拟页。那么问题就变成了如何手动创建页映射,但其实该功能在很多地方都会用到,例如内核在创建新的线程时需要额外创建栈,同样需要用到该功能。
我们将在下一篇文章中对此问题进行展开。
## 小结
本文介绍了两种内存保护技术:分段和分页。前者每次分配的内存区域大小是可变的,但会受到内存碎片的影响;而后者使用固定大小的页,并允许对访问权限进行精确控制。
分页技术将映射信息存储在一级或多级页表中x86_64 平台使用4级页表和4KiB的页大小硬件会自动逐级寻址并将地址转换结果存储在地址转换后备缓冲区TLB然而此缓冲区并非完全对用户透明需要在页表发生变化时进行手动干预。
并且我们知道了内核已经被预定义了一个分页机制,内存越界访问会导致 page fault 异常。并且我们暂时无法访问当前正在使用的页表,因为 CR3 寄存器存储的地址无法在内核中直接访问。
## 下文预告
在下一篇文章中,我们会详细讲解如何在内核中实现对分页机制的支持,这会提供一种直接访问物理内存的特别手段,也就是说我们可以直接访问页表。由此,我们可以在程序中实现虚拟地址到物理地址的转换函数,也使得在页表中手动创建映射成为了可能。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,808 +0,0 @@
+++
title = "ヒープ割り当て"
weight = 10
path = "ja/heap-allocation"
date = 2019-06-26
[extra]
chapter = "Memory Management"
# Please update this when updating the translation
translation_based_on_commit = "afeed7477bb19a29d94a96b8b0620fd241b0d55f"
# GitHub usernames of the people that translated this post
translators = ["woodyZootopia", "garasubo"]
+++
この記事では、私たちのカーネルにヒープ<ruby>割り当て<rp> (</rp><rt>アロケーション</rt><rp>) </rp></ruby>の機能を追加します。まず動的メモリの基礎を説明し、どのようにして借用チェッカがありがちなアロケーションエラーを防いでくれるのかを示します。その後Rustの基本的なアロケーションインターフェースを実装し、ヒープメモリ領域を作成し、アロケータクレートを設定します。この記事を終える頃には、Rustに組み込みの`alloc`クレートのすべてのアロケーション・コレクション型が私たちのカーネルで利用可能になっているでしょう。
<!-- more -->
このブログの内容は [GitHub] 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。また[こちら][at the bottom]にコメントを残すこともできます。この記事の完全なソースコードは[`post-10` ブランチ][post branch]にあります。
[GitHub]: https://github.com/phil-opp/blog_os
[at the bottom]: #comments
<!-- fix for zola anchor checker (target is in template): <a id="comments"> -->
[post branch]: https://github.com/phil-opp/blog_os/tree/post-10
<!-- toc -->
## <ruby>局所<rp> (</rp><rt>ローカル</rt><rp>) </rp></ruby>変数と<ruby>静的<rp> (</rp><rt>スタティック</rt><rp>) </rp></ruby>変数
私たちのカーネルでは現在二種類の変数が使用されています:局所変数と`static`変数です。局所変数は[コールスタック][call stack]に格納されており、変数の定義された関数がリターンするまでの間のみ有効です。静的変数はメモリ上の固定された場所に格納されており、プログラムのライフタイム全体で常に生存しています。
### 局所変数
局所変数は[コールスタック][call stack]に格納されています。これはプッシュ (`push`) とポップ (`pop`) という命令をサポートする[スタックというデータ構造][stack data structure]です。関数に入るたびに、パラメータ、リターンアドレス、呼び出された関数の局所変数がコンパイラによってプッシュされます:
[call stack]: https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%BC%E3%83%AB%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF
[stack data structure]: https://ja.wikipedia.org/wiki/%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF
![outer()とinner(i: usize)関数。両方が局所変数を持っています。outerはinner(1)を呼びます。コールスタックには順に以下の領域がありますouterの局所変数、引数i=1、リターンアドレス、そしてinnerの局所変数。](call-stack.svg)
上の例は、`outer`関数が`inner`関数を呼び出した後のコールスタックを示しています。コールスタックは`outer`の局所変数を先に持っていることが分かります。`inner`を呼び出すと、パラメータ`1`とこの関数のリターンアドレスがプッシュされます。そこで制御は`inner`へと移り、`inner`は自身の局所変数をプッシュします。
`inner`関数がリターンすると、コールスタックのこの関数に対応する部分がポップされ、`outer`の局所変数のみが残ります:
![outerの局所変数しか持っていないコールスタック](call-stack-return.svg)
`inner`関数の局所変数はリターンまでしか生存していないことが分かります。Rustコンパイラはこの<ruby>生存期間<rp> (</rp><rt>ライフタイム</rt><rp>) </rp></ruby>を強制し、私たちが値を長く使いすぎてしまうとエラーを投げます。例えば、局所変数への参照を返そうとしたときがそうです:
```rust
fn inner(i: usize) -> &'static u32 {
let z = [1, 2, 3];
&z[i]
}
```
([この例をplaygroundで実行する](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=6186a0f3a54f468e1de8894996d12819))
上の例の場合、参照を返すことには意味がありませんが、変数に関数よりも長く生存して欲しいというケースは存在します。すでに私たちのカーネルでそのようなケースに遭遇しています。それは[割り込み記述子表 (IDT) を読み込][load an interrupt descriptor table]もうとしたときで、ライフタイムを延ばすために`static`変数を使う必要がありました。
[load an interrupt descriptor table]: @/edition-2/posts/05-cpu-exceptions/index.ja.md#idtwodu-miip-mu
### 静的変数
静的変数は、スタックとは別の固定されたメモリ位置に格納されます。このメモリ位置はコンパイル時にリンカによって指定され、実行可能ファイルにエンコードされています。静的変数はプログラムの実行中ずっと生存するため、`'static`ライフタイムを持っており、局所変数によっていつでも参照することができます。
![同じouter/innerの例ですが、innerが`static Z: [u32; 3] = [1,2,3];`を持っており、参照`&Z[i]`を返します](call-stack-static.svg)
上の例で`inner`関数がリターンするとき、それに対応するコールスタックは破棄されます。(しかし)静的変数は絶対に破棄されない別のメモリ領域にあるため、参照`&Z[1]`はリターン後も有効です。
`'static`ライフタイムの他にも静的変数には利点があります。それらは位置がコンパイル時に分かるため、アクセスするために参照が必要ないのです。この特性を私たちの`println`マクロを作る際に利用しました:[静的な`Writer`][static `Writer`]をその内部で使うことで、マクロを呼び出す際に`&mut Writer`参照が必要でなくなります。これは他の変数にアクセスできない[例外処理関数][exception handlers]においてとても有用です。
[static `Writer`]: @/edition-2/posts/03-vga-text-buffer/index.ja.md#da-yu-de-global-naintahuesu
[exception handlers]: @/edition-2/posts/05-cpu-exceptions/index.ja.md#shi-zhuang
しかし、静的変数のこの特性には重大な欠点がついてきますデフォルトでは読み込み専用なのです。Rustがこのルールを強制するのは、例えば二つのスレッドがある静的変数を同時に変更した場合[データ競合][data race]が発生するためです。静的変数を変更する唯一の方法は、それを[`Mutex`]型にカプセル化し、あらゆる時刻において`&mut`参照が一つしか存在しないことを保証することです。`Mutex`は[VGAバッファへの静的な`Writer`][vga mutex]を作ったときにすでに使いました。
[data race]: https://doc.rust-jp.rs/rust-nomicon-ja/races.html
[`Mutex`]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html
[vga mutex]: @/edition-2/posts/03-vga-text-buffer/index.ja.md#supinrotuku
## <ruby>動的<rp> (</rp><rt>ダイナミック</rt><rp>) </rp></ruby>メモリ
局所変数と静的変数を組み合わせれば、それら自体とても強力であり、ほとんどのユースケースを満足します。しかし、どちらにも制限が存在することも見てきました:
- 局所変数はそれを定義する関数やブロックが終わるまでしか生存しません。なぜなら、これらはコールスタックに存在し、関数がリターンした段階で破棄されるからです。
- 静的変数はプログラムの実行中常に生存するため、必要なくなったときでもメモリを取り戻したり再利用したりする方法がありません。また、所有権のセマンティクスが不明瞭であり、すべての関数からアクセスできてしまうため、変更しようと思ったときには[`Mutex`]で保護してやらないといけません。
局所変数・静的変数の制約としてもう一つ、固定サイズであることが挙げられます。従ってこれらは要素が追加されたときに動的に大きくなるコレクションを格納することができませんRustにおいて動的サイズの局所変数を可能にする[unsized rvalues]の提案が行われていますが、これはいくつかの特定のケースでしかうまく動きません)。
[unsized rvalues]: https://github.com/rust-lang/rust/issues/48055
これらの欠点を回避するために、プログラミング言語はしばしば、変数を格納するための第三の領域である**ヒープ**をサポートします。ヒープは、`allocate``deallocate`という二つの関数を通じて、実行時の**動的メモリ割り当て**をサポートします。仕組みとしては以下のようになります:`allocate`関数は、変数を格納するのに使える、指定されたサイズの解放されたメモリの塊を返します。変数への参照を引数に`deallocate`関数を呼び出すことによってその変数を解放するまで、この変数は生存します。
例を使って見てみましょう:
![inner関数は`allocate(size_of([u32; 3]))`を呼び、`z.write([1,2,3]);`で書き込みを行い、`(z as *mut u32).offset(i)`を返します。outer関数は返された値`y`に対して`deallocate(y, size_of(u32))`を行います。](call-stack-heap.svg)
ここで`inner`関数は`z`を格納するために静的変数ではなくヒープメモリを使っています。まず要求されたサイズのメモリブロックを割り当て、`*mut u32`の[生ポインタ][raw pointer]を受け取ります。その後で[`ptr::write`]メソッドを使ってこれに配列`[1,2,3]`を書き込みます。最後のステップとして、[`offset`]関数を使って`i`番目の要素へのポインタを計算しそれを返します簡単のため、必要なキャストやunsafeブロックをいくつか省略しました
[raw pointer]: https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html#%E7%94%9F%E3%83%9D%E3%82%A4%E3%83%B3%E3%82%BF%E3%82%92%E5%8F%82%E7%85%A7%E5%A4%96%E3%81%97%E3%81%99%E3%82%8B
[`ptr::write`]: https://doc.rust-lang.org/core/ptr/fn.write.html
[`offset`]: https://doc.rust-lang.org/std/primitive.pointer.html#method.offset
割り当てられたメモリは`deallocate`の呼び出しによって明示的に解放されるまで生存します。したがって、返されたポインタは、`inner`がリターンしコールスタックの対応する部分が破棄された後も有効です。スタティックメモリと比較したときのヒープメモリの長所は、解放(`outer`内の`deallocate`呼び出しでまさにこれを行っています)後に再利用できるということです。この呼び出しの後、状況は以下のようになります。
![コールスタックはouterの局所変数を持っており、ヒープはz[0]とz[2]を持っているが、z[1]はもう持っていない。](call-stack-heap-freed.svg)
`z[1]`スロットが解放され、次の`allocate`呼び出しで再利用できることが分かります。しかし、`z[0]``z[2]`は永久にdeallocateされず、したがって永久に解放されないことも分かります。このようなバグは**メモリリーク**と呼ばれており、しばしばプログラムの過剰なメモリ消費を引き起こします(`inner`をループで何度も呼び出したらどんなことになるか、想像してみてください)。これ自体良くないことに思われるかもしれませんが、動的割り当てはもっと危険性の高いバグを発生させうるのです。
### よくあるミス
メモリリークは困りものですが、プログラムを攻撃者に対して脆弱にはしません。しかしこのほかに、より深刻な結果を招く二種類のバグが存在します:
- もし変数に対して`deallocate`を呼んだ後にも間違ってそれを使い続けたら、いわゆる<ruby>use-after-free<rp> (</rp><rt>メモリ解放後に使用</rt><rp>) </rp></ruby>脆弱性が発生します。このようなバグは未定義動作を引き起こし、しばしば攻撃者が任意コードを実行するのに利用されます。
- 間違ってある変数を二度解放したら、<ruby>double-free<rp> (</rp><rt>二重解放</rt><rp>) </rp></ruby>脆弱性が発生します。これが問題になるのは、最初の`deallocate`呼び出しの後に同じ場所にallocateされた別の割り当てを解放してしまうかもしれないからです。従って、これもまたuse-after-free脆弱性につながりかねません。
これらの脆弱性は広く知られているため、回避する方法も解明されているはずだとお思いになるかもしれません。しかし答えはいいえで、このような脆弱性は未だ散見され、例えば最近でも任意コード実行を許す[Linuxのuse-after-free脆弱性][linux vulnerability]が存在しました。このことは、最高のプログラマーであっても、複雑なプロジェクトにおいて常に正しく動的メモリを扱えはしないということを示しています。
[linux vulnerability]: https://securityboulevard.com/2019/02/linux-use-after-free-vulnerability-found-in-linux-2-6-through-4-20-11/
これらの問題を回避するため、JavaやPythonといった多くの言語では[**ガベージコレクション**][_garbage collection_]という技術を使って自動的に動的メモリを管理しています。発想としては、プログラマが絶対に自分の手で`deallocate`を呼び出すことがないようにするというものです。代わりに、プログラムが定期的に一時停止されてスキャンされ、未使用のヒープ変数が見つかったら自動的にdeallocateされるのです。従って、上のような脆弱性は絶対に発生し得ません。欠点としては定期的にスキャンすることによる性能のオーバーヘッドが発生することと、一時停止の時間が長くなりがちであることが挙げられます。
[_garbage collection_]: https://ja.wikipedia.org/wiki/%E3%82%AC%E3%83%99%E3%83%BC%E3%82%B8%E3%82%B3%E3%83%AC%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3
Rustはこの問題に対して別のアプローチを取ります[**所有権**][_ownership_]と呼ばれる概念を使って、動的メモリの操作の正確性をコンパイル時にチェックするのです。従って前述の脆弱性を回避するためのガベージコレクションの必要がなく、性能のオーバーヘッドが存在しません。このアプローチのもう一つの利点として、CやC++と同様、プログラマが動的メモリの使用に関して精緻な制御を行うことができるということが挙げられます。
[_ownership_]: https://doc.rust-jp.rs/book-ja/ch04-01-what-is-ownership.html
### Rustにおける割り当て
プログラマーに自分の手で`allocate``deallocate`を呼ばせる代わりに、Rustの標準ライブラリはこれらの関数を暗黙の内に呼ぶ抽象型を提供しています。最も重要な型は[**`Box`**]で、これはヒープに割り当てられた値の抽象化です。これは[`Box::new`]コンストラクタ関数を提供しており、これは値を引数として、その値のサイズを引数に`allocate`を呼び出し、ヒープ上に新しく割り当てられたスロットにその値を<ruby>移動<rp> (</rp><rt>ムーブ</rt><rp>) </rp></ruby>します。ヒープメモリを解放するために、スコープから出た際に`deallocate`を呼ぶような[`Drop`トレイト][`Drop` trait]を`Box`型は実装しています。
[**`Box`**]: https://doc.rust-lang.org/std/boxed/index.html
[`Box::new`]: https://doc.rust-lang.org/alloc/boxed/struct.Box.html#method.new
[`Drop` trait]: https://doc.rust-jp.rs/book-ja/ch15-03-drop.html
```rust
{
let z = Box::new([1,2,3]);
[]
} // zがスコープから出たので`deallocate`が呼ばれる
```
このような記法のパターンは[リソース取得は初期化である][_resource acquisition is initialization_]resource acquisition is initialization、略してRAIIという奇妙な名前を持っています。C++で[`std::unique_ptr`]という同じような抽象型を実装するのに使われたのが始まりです。
[_resource acquisition is initialization_]: https://ja.wikipedia.org/wiki/RAII
[`std::unique_ptr`]: https://en.cppreference.com/w/cpp/memory/unique_ptr
このような型自体ではすべてのuse-after-freeバグを防ぐのに十分ではありません。なぜなら、プログラマは、`Box`がスコープ外に出て対応するヒープメモリスロットがdeallocateされた後でも参照を利用し続けることができてしまうからです
```rust
let x = {
let z = Box::new([1,2,3]);
&z[1]
}; // zがスコープから出たので`deallocate`が呼ばれる
println!("{}", x);
```
ここでRustの所有権の出番です。所有権システムは、参照が有効なスコープを表す抽象[ライフタイム][lifetime]をそれぞれの参照に指定します。上の例では、参照`x`は配列`z`から取られているので、`z`がスコープ外に出ると無効になります。[上の例をplaygroundで実行する][playground-2]と、確かにRustコンパイラがエラーを投げるのが分かります
[lifetime]: https://doc.rust-jp.rs/book-ja/ch10-03-lifetime-syntax.html
[playground-2]: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=28180d8de7b62c6b4a681a7b1f745a48
```
error[E0597]: `z[_]` does not live long enough
--> src/main.rs:4:9
|
2 | let x = {
| - borrow later stored here
3 | let z = Box::new([1,2,3]);
4 | &z[1]
| ^^^^^ borrowed value does not live long enough
5 | }; // z goes out of scope and `deallocate` is called
| - `z[_]` dropped here while still borrowed
```
ここで使われている用語は初見では少しわかりにくいかもしれません。値の参照を取ることは値を借用する (borrow) と呼ばれています。これは現実での借用と似ているためですオブジェクトに一時的にアクセスできるようになりますが、それをいつか返さなければならず、また破壊することも許されません。オブジェクトが破壊される前にすべての借用が終了することを確かめることにより、Rustコンパイラはuse-after-freeが起こりえないことを保証できるのです。
Rustの所有権システムはさらに突き詰められており、use-after-freeバグを防ぐだけでなく、JavaやPythonのようなガベージコレクション型言語と同じ完全な[メモリ<ruby>安全性<rp> (</rp><rt>セーフティ</rt><rp>) </rp></ruby>][_memory safety_]を提供しています。さらに[スレッド<ruby>安全性<rp> (</rp><rt>セーフティ</rt><rp>) </rp></ruby>][_thread safety_]も保証されており、マルチスレッドのプログラムにおいてはこれらの言語よりもさらに安全です。さらに最も重要なことに、これらのチェックは全てコンパイル時に行われるため、C言語で手書きされたメモリ管理と比べても実行時のオーバーヘッドはありません。
[_memory safety_]: https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%A2%E3%83%AA%E5%AE%89%E5%85%A8%E6%80%A7
[_thread safety_]: https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%AC%E3%83%83%E3%83%89%E3%82%BB%E3%83%BC%E3%83%95
### 使用例
Rustにおける動的メモリ割り当ての基礎を学んだわけですが、これをいつ使えば良いのでしょうか私たちのカーネルは動的メモリ割り当てなしにこれだけやってこられたのに、どうして今になってこれが必要なのでしょうか
まず覚えておいて欲しいのは、割り当てを行うたびにヒープから空いているスロットを探してこないといけないので、動的メモリ割り当てには少しだけ性能オーバーヘッドがあるということです。このため、特に性能が重要となるカーネルのプログラムにおいては、一般に局所変数の方が好ましいです。しかし、動的メモリ割り当てが最良の選択肢であるようなケースも存在するのです。
基本的なルールとして、動的メモリは動的なライフタイムや可変サイズを持つような変数に必要とされます。動的なライフタイムを持つ最も重要な型は[**`Rc`**]で、これはラップされた値に対する参照を数えておき、すべての参照がスコープから外れたらそれをdeallocateするというものです。可変サイズを持つ型の例には、[**`Vec`**]、[**`String`**]、その他の[コレクション型][collection types]といった、要素が追加されたときに動的に大きくなるような型が挙げられます。これらの型は、容量が一杯になると、より大きい量のメモリを割り当て、すべての要素をコピーし、古い割り当てをdeallocateすることにより対処します。
[**`Rc`**]: https://doc.rust-lang.org/alloc/rc/index.html
[**`Vec`**]: https://doc.rust-lang.org/alloc/vec/index.html
[**`String`**]: https://doc.rust-lang.org/alloc/string/index.html
[collection types]: https://doc.rust-lang.org/alloc/collections/index.html
私たちのカーネルでは主にコレクション型を必要とし、例えば、将来の記事でマルチタスキングを実行するときにアクティブなタスクのリストを格納するために使います。
## アロケータインターフェース
ヒープアロケータを実装するための最初のステップは、組み込みの[`alloc`]クレートへの依存関係を追加することです。[`core`]クレートと同様、これは標準ライブラリのサブセットであり、アロケーション型やコレクション型を含んでいます。`alloc`への依存関係を追加するために、以下を`lib.rs`に追加します:
[`alloc`]: https://doc.rust-lang.org/alloc/
[`core`]: https://doc.rust-lang.org/core/
```rust
// in src/lib.rs
extern crate alloc;
```
通常の依存関係と異なり`Cargo.toml`を修正する必要はありません。その理由は、`alloc`クレートは標準ライブラリの一部としてRustコンパイラに同梱されているため、コンパイラはすでにこのクレートのことを知っているからです。この`extern crate`宣言を追加することで、コンパイラにこれをインクルードしようと試みるよう指定しています(昔はすべての依存関係が`extern crate`宣言を必要としていたのですが、いまは任意です)。
<div class="note">
**訳者注:** 詳しくは[edition guideの対応するページ](https://doc.rust-jp.rs/edition-guide/rust-2018/path-changes.html#%E3%81%95%E3%82%88%E3%81%86%E3%81%AA%E3%82%89extern-crate)をご覧ください。
</div>
カスタムターゲット向けにコンパイルしようとしているので、Rustインストール時に同梱されていたコンパイル済みの`alloc`を使用することはできません。代わりにcargoにこのクレートをソースから再コンパイルするよう命令する必要があります。これは、配列`unstable.build-std``.cargo/config.toml`ファイルに追加することで行えます。
```toml
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins", "alloc"]
````
`alloc`
`alloc``#[no_std]`なクレートで標準では無効化されている理由は、これが追加の要件を持っているからです。今私たちのプロジェクトをコンパイルしようとすると、その要件をエラーとして目にすることになります:
```
error: no global memory allocator found but one is required; link to std or add
#[global_allocator] to a static item that implements the GlobalAlloc trait.
(エラー:グローバルメモリアロケータが見つかりませんが、一つ必要です。
 stdをリンクするか、GlobalAllocトレイトを実装する静的な要素に#[global_allocator]を付けてください。)
error: `#[alloc_error_handler]` function required, but not found
(エラー:`#[alloc_error_handler]`関数が必要ですが、見つかりません)
```
最初のエラーは、`alloc`クレートが、ヒープアロケータという`allocate`と`deallocate`関数を提供するオブジェクトを必要とするために発生します。Rustにおいては、ヒープアロケータの満たすべき性質は[`GlobalAlloc`]トレイトによって記述されており、エラーメッセージでもそのことについて触れられています。クレートのヒープアロケータを設定するためには、`#[global_allocator]`属性を`GlobalAlloc`トレイトを実装する何らかの`static`変数に適用する必要があります。
二つ目のエラーは、(主にメモリが不足している場合)`allocate`の呼び出しが失敗しうるために発生します。私たちのプログラムはこのケースに対処できるようになっている必要があり、そのために使われる関数が`#[alloc_error_handler]`なのです。
[`GlobalAlloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html
次のセクションでこのトレイトと属性について説明します。
### `GlobalAlloc`トレイト
[`GlobalAlloc`]トレイトはヒープアロケータの提供しなければならない関数を定義します。このトレイトは、プログラマが絶対に直接使わないという点において特別です。代わりに、`alloc`のアロケーション・コレクション型を使うときに、コンパイラがトレイトメソッドへの適切な呼び出しを自動的に挿入します。
このトレイトを私たちのアロケータ型全てに実装しなければならないので、その宣言は詳しく見ておく価値があるでしょう:
```rust
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { ... }
unsafe fn realloc(
&self,
ptr: *mut u8,
layout: Layout,
new_size: usize
) -> *mut u8 { ... }
}
```
このトレイトは[`alloc`]と[`dealloc`]という必須メソッドを定義しており、これは上の例で使った`allocate``deallocate`関数に相当します:
- [`alloc`]メソッドは[`Layout`]インスタンス(割り当てられたメモリの持つべきサイズとアラインメントを記述する)を引数として取ります。メソッドは割り当てられたメモリブロックの最初のバイトへの[生ポインタ][raw pointer]を返します。割り当てエラーが起きたことを示す際は、明示的なエラー値を返す代わりにヌルポインタを返します。このやり方はRustの慣習とはやや外れていますが、同じ慣習に従っている既存のシステムのアロケータをラップするのが簡単になるという利点があります。
- [`dealloc`]はその対で、メモリブロックを開放する役割を持ちます。このメソッドは、`alloc`によって返されたポインタと割り当ての際に使われた`Layout`という二つの引数を取ります。
[`alloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#tymethod.alloc
[`dealloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#tymethod.dealloc
[`Layout`]: https://doc.rust-lang.org/alloc/alloc/struct.Layout.html
このトレイトは[`alloc_zeroed`]と[`realloc`]という二つのデフォルト実装付きメソッドも定義しています。
- [`alloc_zeroed`]メソッドは`alloc`を呼んでから割り当てられたメモリブロックの値を0にするのに等しく、デフォルト実装でもまさに同じことをしています。もし、より効率的なカスタム実装があるならば、デフォルト実装を上書きすることもできます。
- [`realloc`]メソッドは割り当てたメモリを拡大したり縮小したりすることができます。デフォルト実装では、要求されたサイズの新しいメモリブロックを割り当て、以前のアロケーションから中身を全てコピーします。同じく、アロケータの実装によってはこのメソッドをより効率的に実装することができるかもしれません。例えば、可能な場合はその場でアロケーションを拡大・縮小するなど。
[`alloc_zeroed`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#method.alloc_zeroed
[`realloc`]: https://doc.rust-lang.org/alloc/alloc/trait.GlobalAlloc.html#method.realloc
#### Unsafe
トレイト自体とすべてのトレイトメソッドが`unsafe`として宣言されていることに気をつけましょう:
- トレイトを`unsafe`として宣言する理由は、プログラマがアロケータ型のトレイト実装が正しいことを保証しなければならないからです。例えば、`alloc`メソッドは他のどこかですでに使用されているメモリブロックを決して返してはならず、もしそうすると未定義動作が発生してしまいます。
- 同様に、メソッドが`unsafe`である理由は、メソッドを呼び出す際に呼び出し元がいくつかの不変条件を保証しなければならないからです。例えば、`alloc`に渡される`Layout`の指定するサイズが非ゼロであることなどです。実際にはこれは大して重要ではなく、というのもこれらのメソッドはコンパイラによって直接呼び出されるため、これらの要件が満たされていることは保証されているからです。
### `DummyAllocator`
アロケータ型が何を提供しないといけないかを理解したので、シンプルな<ruby>ダミー<rp> (</rp><rt>ハリボテ</rt><rp>) </rp></ruby>のアロケータを作ることができます。そのためまず新しく`allocator`モジュールを作りましょう:
```rust
// in src/lib.rs
pub mod allocator;
```
私たちのダミーアロケータでは、トレイトを実装するための最小限のことしかせず、`alloc`が呼び出されたら常にエラーを返すようにします。以下のようになります:
```rust
// in src/allocator.rs
use alloc::alloc::{GlobalAlloc, Layout};
use core::ptr::null_mut;
pub struct Dummy;
unsafe impl GlobalAlloc for Dummy {
unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
null_mut()
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
panic!("dealloc should be never called")
}
}
```
この構造体はフィールドを必要としないので、[サイズがゼロの型][zero sized type]として作成します。上で述べたように、`alloc`は常に割り当てエラーに相当するヌルポインタを返すようにします。アロケータがメモリを返すことは絶対に起きないのだから、`dealloc`の呼び出しも絶対に起きないはずです。このため`dealloc`メソッドでは単にpanicすることにします。`alloc_zeroed``realloc`メソッドにはデフォルト実装があるので、これらを実装する必要はありません。
[zero sized type]: https://doc.rust-jp.rs/rust-nomicon-ja/exotic-sizes.html#%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%8C-0-%E3%81%AE%E5%9E%8Bzst-zero-sized-type
こうして単純なアロケータを手に入れたわけですが、さらにRustコンパイラにこのアロケータを使うよう指示しないといけません。ここで`#[global_allocator]`属性の出番です。
### `#[global_allocator]`属性
`#[global_allocator]`属性は、どのアロケータインスタンスをグローバルヒープアロケータとして使うべきかをRustコンパイラに指示します。この属性は`GlobalAlloc`トレイトを実装する`static`にのみ適用できます。私たちの`Dummy`アロケータのインスタンスをグローバルアロケータとして登録してみましょう:
```rust
// in src/allocator.rs
#[global_allocator]
static ALLOCATOR: Dummy = Dummy;
```
`Dummy`アロケータは[サイズがゼロの型][zero sized type]なので、初期化式でフィールドを指定する必要はありません。
これをコンパイルしようとすると、最初のエラーは消えているはずです。残っている二つ目のエラーを修正しましょう:
```
error: `#[alloc_error_handler]` function required, but not found
```
### `#[alloc_error_handler]`属性
`GlobalAlloc`トレイトについて議論したときに学んだように、`alloc`関数はヌルポインタを返すことによって割り当てエラーを示します。ここで生じる疑問は、そのように割り当てが失敗したときRustランタイムはどう対処するべきなのかということです。ここで`#[alloc_error_handler]`属性の出番です。この属性は、パニックが起こったときにパニックハンドラが呼ばれるのと同じように、割り当てエラーが起こったときに呼ばれる関数を指定するのです。
コンパイルエラーを修正するためにそのような関数を追加してみましょう:
```rust
// in src/lib.rs
#![feature(alloc_error_handler)] // ファイルの先頭に書く
#[alloc_error_handler]
fn alloc_error_handler(layout: alloc::alloc::Layout) -> ! {
panic!("allocation error: {:?}", layout)
}
```
`alloc_error_handler`関数はまだunstableなので、feature gateによってこれを有効化する必要があります。この関数は引数を一つ取ります割り当てエラーが起こったとき`alloc`関数に渡されていた`Layout`のインスタンスです。割り当ての失敗を解決するためにできることはないので、`Layout`インスタンスを含めたメッセージを表示してただpanicすることにしましょう。
この関数を追加したことで、コンパイルエラーは修正されたはずです。これで`alloc`のアロケーション・コレクション型を使えるようになりました。例えば、[`Box`]を使ってヒープに値を割り当てることができます:
[`Box`]: https://doc.rust-lang.org/alloc/boxed/struct.Box.html
```rust
// in src/main.rs
extern crate alloc;
use alloc::boxed::Box;
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// […] "Hello World!"を表示, `init`の呼び出し, `mapper`と`frame_allocator`を作成
let x = Box::new(41);
// […] テストモードでは`test_main`を呼ぶ
println!("It did not crash!");
blog_os::hlt_loop();
}
```
`main.rs`においても`extern crate alloc`文を指定する必要があることに注意してください。`lib.rs``main.rs`は別のクレートとして取り扱われているためです。しかしながら、グローバルアロケータはプロジェクト内のすべてのクレートに適用されるため、`#[global_allocator]`静的変数をもう一つ作る必要はありません。実際、別のクレートで新しいアロケータを指定するとエラーになります。
上のコードを実行すると、`alloc_error_handler`関数が呼ばれるのが分かります:
![QEMUが"panicked at `allocation error: Layout { size_: 4, align_: 4 }, src/lib.rs:89:5"と出力している。](qemu-dummy-output.png)
`Box::new`関数は暗黙のうちにグローバルアロケータの`alloc`関数を呼び出すため、エラーハンドラが呼ばれました。私たちのダミーアロケータは常にヌルポインタを返すので、あらゆる割り当てが失敗するのです。これを修正するためには、使用可能なメモリを実際に返すアロケータを作る必要があります。
## Creating a Kernel Heap
適切なアロケータを作りたいですが、その前にまず、そのアロケータがメモリを割り当てるためのヒープメモリ領域を作らないといけません。このために、ヒープ領域のための仮想メモリ範囲を定義し、その領域を物理フレームに対応付ける必要があります。仮想メモリとページテーブルの概要については、[ページング入門][_"Introduction To Paging"_]の記事を読んでください。
[_"Introduction To Paging"_]: @/edition-2/posts/08-paging-introduction/index.ja.md
最初のステップはヒープのための仮想メモリ領域を定義することです。他のメモリ領域に使われていない限り、どんな仮想アドレス範囲でも構いません。ここでは、あとからそこがヒープポインタだと簡単に分かるよう、`0x_4444_4444_0000`から始まるメモリとしましょう。
```rust
// in src/allocator.rs
pub const HEAP_START: usize = 0x_4444_4444_0000;
pub const HEAP_SIZE: usize = 100 * 1024; // 100 KiB
```
今のところヒープの大きさは100 KiBとします。将来より多くの領域が必要になったら大きくすれば良いです。
今このヒープ領域を使おうとすると、仮想メモリ領域が物理メモリにまだ対応付けられていないためページフォルトが発生します。これを解決するために、[ページング入門][_"Paging Implementation"_]の記事で導入した[`Mapper` API]を使ってヒープページを対応付ける関数`init_heap`を作ります:
[`Mapper` API]: @/edition-2/posts/09-paging-implementation/index.ja.md#offsetpagetablewoshi-u
[_"Paging Implementation"_]: @/edition-2/posts/09-paging-implementation/index.ja.md
```rust
// in src/allocator.rs
use x86_64::{
structures::paging::{
mapper::MapToError, FrameAllocator, Mapper, Page, PageTableFlags, Size4KiB,
},
VirtAddr,
};
pub fn init_heap(
mapper: &mut impl Mapper<Size4KiB>,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
let page_range = {
let heap_start = VirtAddr::new(HEAP_START as u64);
let heap_end = heap_start + HEAP_SIZE - 1u64;
let heap_start_page = Page::containing_address(heap_start);
let heap_end_page = Page::containing_address(heap_end);
Page::range_inclusive(heap_start_page, heap_end_page)
};
for page in page_range {
let frame = frame_allocator
.allocate_frame()
.ok_or(MapToError::FrameAllocationFailed)?;
let flags = PageTableFlags::PRESENT | PageTableFlags::WRITABLE;
unsafe {
mapper.map_to(page, frame, flags, frame_allocator)?.flush()
};
}
Ok(())
}
```
この関数は[`Mapper`]と[`FrameAllocator`]への可変参照を取ります。これらはどちらも[`Size4KiB`]をジェネリックパラメータとすることで4KiBページのみに制限しています。この関数の戻り値は[`Result`]で、成功ヴァリアントが`()`、失敗ヴァリアントが([`Mapper::map_to`]メソッドによって失敗時に返されるエラー型である)[`MapToError`]です。この関数における主なエラーの原因は`map_to`メソッドであるため、このエラー型を流用するのは理にかなっています。
[`Mapper`]:https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html
[`FrameAllocator`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.FrameAllocator.html
[`Size4KiB`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/enum.Size4KiB.html
[`Result`]: https://doc.rust-lang.org/core/result/enum.Result.html
[`MapToError`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/enum.MapToError.html
[`Mapper::map_to`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/trait.Mapper.html#method.map_to
実装内容は以下の二つに分けられます:
- **ページ範囲の作成:** 対応付けたいページ領域を作成するために、ポインタ`HEAP_START`を[`VirtAddr`]型に変換します。つぎに`HEAP_SIZE`を足すことによってヒープの終端アドレスを計算します。<ruby>端が含まれる境界<rp> (</rp><rt>インクルーシブレンジ</rt><rp>) </rp></ruby>にしたいヒープの最後のバイトのアドレスとしたいので1を引きます。次に、これらのアドレスを[`containing_address`]関数を使って[`Page`]型に変換します。最後に、[`Page::range_inclusive`]関数を使って最初と最後のページからページ範囲を作成します。
- **ページの<ruby>対応付け<rp> (</rp><rt>マッピング</rt><rp>) </rp></ruby>:** 二つ目のステップは、今作ったページ範囲のすべてのページに対して対応付けを行うことです。これを行うため、`for`ループを使ってこのページ範囲に対して繰り返し処理を行います。それぞれのページに対して以下を行います:
- [`FrameAllocator::allocate_frame`]メソッドを使って、ページのマップされるべき物理フレームを割り当てます。このメソッドはもうフレームが残っていないとき[`None`]を返します。このケースに対処するため、[`Option::ok_or`]メソッドを使ってこれを[`MapToError::FrameAllocationFailed`]に変換し、エラーの場合は[`?`演算子][question mark operator]を使って早期リターンしています。
- このページに対し、必要となる`PRESENT`フラグと`WRITABLE`フラグをセットします。これらのフラグにより読み書きのアクセスが許可されますが、これはヒープメモリとして理にかなっています。
- [`Mapper::map_to`]メソッドを使ってアクティブなページテーブルに対応付けを作成します。このメソッドは失敗しうるので、同様に[`?`演算子][question mark operator]を使ってエラーを呼び出し元に受け渡します。成功時には、このメソッドは[`MapperFlush`]インスタンスを返しますが、これを使って[`flush`]メソッドを呼ぶことで[**トランスレーション・ルックアサイド・バッファ**][_translation lookaside buffer_]を更新することができます。
[`VirtAddr`]: https://docs.rs/x86_64/0.14.2/x86_64/addr/struct.VirtAddr.html
[`Page`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/struct.Page.html
[`containing_address`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/struct.Page.html#method.containing_address
[`Page::range_inclusive`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/page/struct.Page.html#method.range_inclusive
[`FrameAllocator::allocate_frame`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/trait.FrameAllocator.html#tymethod.allocate_frame
[`None`]: https://doc.rust-lang.org/core/option/enum.Option.html#variant.None
[`MapToError::FrameAllocationFailed`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/enum.MapToError.html#variant.FrameAllocationFailed
[`Option::ok_or`]: https://doc.rust-lang.org/core/option/enum.Option.html#method.ok_or
[question mark operator]: https://doc.rust-jp.rs/book-ja/ch09-02-recoverable-errors-with-result.html#%E3%82%A8%E3%83%A9%E3%83%BC%E5%A7%94%E8%AD%B2%E3%81%AE%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88-%E6%BC%94%E7%AE%97%E5%AD%90
[`MapperFlush`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MapperFlush.html
[_translation lookaside buffer_]: @/edition-2/posts/08-paging-introduction/index.ja.md#toransuresiyonrutukuasaidobatuhua
[`flush`]: https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.MapperFlush.html#method.flush
最後のステップは、この関数を`kernel_main`から呼び出すことです:
```rust
// in src/main.rs
fn kernel_main(boot_info: &'static BootInfo) -> ! {
use blog_os::allocator; // 新しいインポート
use blog_os::memory::{self, BootInfoFrameAllocator};
println!("Hello World{}", "!");
blog_os::init();
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let mut mapper = unsafe { memory::init(phys_mem_offset) };
let mut frame_allocator = unsafe {
BootInfoFrameAllocator::init(&boot_info.memory_map)
};
// ここを追加
allocator::init_heap(&mut mapper, &mut frame_allocator)
.expect("heap initialization failed");
let x = Box::new(41);
// […] テストモードでは`test_main`を呼ぶ
println!("It did not crash!");
blog_os::hlt_loop();
}
```
ここで、文脈が分かるよう関数の全体を示しています。(しかし)新しい行は`blog_os::allocator`のインポートと`allocator::init_heap`の呼び出しだけです。`init_heap`関数がエラーを返した場合、これを処理する良い方法は今のところないため、[`Result::expect`]メソッドを使ってパニックします。
[`Result::expect`]: https://doc.rust-lang.org/core/result/enum.Result.html#method.expect
これで、使用する準備のできた、対応付けられたヒープメモリ領域を手に入れました。`Box::new`の呼び出しはまだ私たちの古い`Dummy`アロケータを使っているので、実行しても依然として「メモリ不足」のエラーを見ることになるでしょう。適切なアロケータを使うようにして、このエラーを修正してみましょう。
## アロケータクレートを使う
アロケータを実装するのは少々複雑なので、まずは既製のアロケータを使うことにしましょう。アロケータを自作する方法については次の記事で学びます。
`no_std`のアプリケーションのためのシンプルなアロケータのひとつに[`linked_list_allocator`]クレートがあります。この名前は、割り当てられていないメモリ領域を連結リストを使って管理しているところから来ています。この手法のより詳しい説明については次の記事を読んでください。
このクレートを使うためには、まず依存関係を`Cargo.toml`に追加する必要があります:
[`linked_list_allocator`]: https://github.com/phil-opp/linked-list-allocator/
```toml
# in Cargo.toml
[dependencies]
linked_list_allocator = "0.9.0"
```
次に私たちのダミーアロケータをこのクレートによって提供されるアロケータで置き換えます:
```rust
// in src/allocator.rs
use linked_list_allocator::LockedHeap;
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
```
この構造体は同期のために`spinning_top::Spinlock`型を使うため`LockedHeap`という名前が付いています。これが必要なのは、`ALLOCATOR`静的変数に複数のスレッドが同時にアクセスすることがありえるからです。スピンロックやmutexを使うときはいつもそうであるように、誤ってデッドロックを起こさないように注意する必要があります。これが意味するのは、我々は割り込みハンドラ内で一切アロケーションを行ってはいけないと言うことです。なぜなら、割り込みハンドラはどんなタイミングでも走る可能性があるため、進行中のアロケーションに割り込んでいることがあるからです。
[`spinning_top::Spinlock`]: https://docs.rs/spinning_top/0.1.0/spinning_top/type.Spinlock.html
`LockedHeap`をグローバルアロケータとして設定するだけでは十分ではありません。いま[`empty`]コンストラクタ関数を使っていますが、この関数はメモリを与えることなくアロケータを作るからです。私たちのダミーアロケータと同じく、これ(今の状態の`LockedHeap`)は`alloc`を行うと常にエラーを返します。この問題を修正するため、ヒープを作った後でアロケータを初期化する必要があります:
[`empty`]: https://docs.rs/linked_list_allocator/0.9.0/linked_list_allocator/struct.LockedHeap.html#method.empty
```rust
// in src/allocator.rs
pub fn init_heap(
mapper: &mut impl Mapper<Size4KiB>,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<(), MapToError<Size4KiB>> {
// […] すべてのヒープページを物理フレームにマップする
// new
unsafe {
ALLOCATOR.lock().init(HEAP_START, HEAP_SIZE);
}
Ok(())
}
```
`LockedHeap`型の内部のスピンロックの[`lock`]メソッドを呼ぶことで、ラップされた[`Heap`]インスタンスへの排他参照を得て、これの[`init`]メソッドをヒープの境界を引数として呼んでいます。`init`関数自体がヒープメモリに書き込もうとするので、ヒープページを対応付けた **後に** ヒープを初期化することが重要です。
[`lock`]: https://docs.rs/lock_api/0.3.3/lock_api/struct.Mutex.html#method.lock
[`Heap`]: https://docs.rs/linked_list_allocator/0.9.0/linked_list_allocator/struct.Heap.html
[`init`]: https://docs.rs/linked_list_allocator/0.9.0/linked_list_allocator/struct.Heap.html#method.init
ヒープを初期化できたら、組み込みの[`alloc`]クレートのあらゆるアロケーション・コレクション型がエラーなく使用できます:
```rust
// in src/main.rs
use alloc::{boxed::Box, vec, vec::Vec, rc::Rc};
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// […] initialize interrupts, mapper, frame_allocator, heap
// ヒープに数字をアロケートする
let heap_value = Box::new(41);
println!("heap_value at {:p}", heap_value);
// 動的サイズのベクタを作成する
let mut vec = Vec::new();
for i in 0..500 {
vec.push(i);
}
println!("vec at {:p}", vec.as_slice());
// 参照カウントされたベクタを作成する -> カウントが0になると解放される
let reference_counted = Rc::new(vec![1, 2, 3]);
let cloned_reference = reference_counted.clone();
println!("current reference count is {}", Rc::strong_count(&cloned_reference));
core::mem::drop(reference_counted);
println!("reference count is {} now", Rc::strong_count(&cloned_reference));
// […] テストでは `test_main` を呼ぶ
println!("It did not crash!");
blog_os::hlt_loop();
}
```
このコード例では[`Box`], [`Vec`], [`Rc`]型を使ってみました。`Box`型と`Vec`型については対応するヒープポインタを[`{:p}`フォーマット指定子][`{:p}` formatting specifier]を使って出力しています。`Rc`についての例を示すために、参照カウントされたヒープ値を作成し、インスタンスを([`core::mem::drop`]を使って)ドロップする前と後に[`Rc::strong_count`]関数を使って現在の参照カウントを出力しています。
[`Vec`]: https://doc.rust-lang.org/alloc/vec/
[`Rc`]: https://doc.rust-lang.org/alloc/rc/
[`{:p}` formatting specifier]: https://doc.rust-lang.org/core/fmt/trait.Pointer.html
[`Rc::strong_count`]: https://doc.rust-lang.org/alloc/rc/struct.Rc.html#method.strong_count
[`core::mem::drop`]: https://doc.rust-lang.org/core/mem/fn.drop.html
実行すると、以下のような結果を得ます:
![QEMUが`
heap_value at 0x444444440000
vec at 0x4444444408000
current reference count is 2
reference count is 1 now
`と出力している](qemu-alloc-showcase.png)
ポインタが`0x_4444_4444_*`で始まることから、`Box``Vec`の値は想定通りヒープ上にあることが分かります。参照カウントされた値も期待したとおり振る舞っており、`clone`呼び出しの後では参照カウントは2になり、インスタンスの一方がドロップされた後では再び1になっています。
ベクタがヒープメモリの先頭から`0x800`だけずれた場所から始まるのは、Box内の値が`0x800`バイトの大きさがあるためではなく、ベクタが容量を増やさなければならないときに発生する[<ruby>再割り当て<rp> (</rp><rt>リアロケーション</rt><rp>) </rp></ruby>][reallocations]のためです。例えば、ベクタの容量が32の際に次の要素を追加しようとすると、ベクタは内部で容量64の配列を新たに割り当て、すべての要素をコピーします。その後古い割り当てを解放しています。
[reallocations]: https://doc.rust-lang.org/alloc/vec/struct.Vec.html#capacity-and-reallocation
もちろん`alloc`クレートにはもっと多くのアロケーション・コレクション型があり、今やそれらのすべてを私たちのカーネルで使うことができます。それには以下が含まれます:
- スレッドセーフな参照カウントポインタ[`Arc`]
- 文字列を所有する型[`String`]と[`format!`]マクロ
- [`LinkedList`]
- 必要に応じてサイズを大きくできるリングバッファ[`VecDeque`]
- プライオリティキューである[`BinaryHeap`]
- [`BTreeMap`]と[`BTreeSet`]
[`Arc`]: https://doc.rust-lang.org/alloc/sync/struct.Arc.html
[`String`]: https://doc.rust-lang.org/alloc/string/struct.String.html
[`format!`]: https://doc.rust-lang.org/alloc/macro.format.html
[`LinkedList`]: https://doc.rust-lang.org/alloc/collections/linked_list/struct.LinkedList.html
[`VecDeque`]: https://doc.rust-lang.org/alloc/collections/vec_deque/struct.VecDeque.html
[`BinaryHeap`]: https://doc.rust-lang.org/alloc/collections/binary_heap/struct.BinaryHeap.html
[`BTreeMap`]: https://doc.rust-lang.org/alloc/collections/btree_map/struct.BTreeMap.html
[`BTreeSet`]: https://doc.rust-lang.org/alloc/collections/btree_set/struct.BTreeSet.html
これらの型は、スレッドリスト、スケジュールキュー、async/awaitのサポートを実装しようとするときにとても有用になります。
## テストを追加する
いま新しく作ったアロケーションコードを間違って壊してしまうことがないことを保証するために、<ruby>結合<rp> (</rp><rt>インテグレーション</rt><rp>) </rp></ruby>テストを追加するべきでしょう。まず、次のような内容のファイル`tests/heap_allocation.rs`を作成します。
```rust
// in tests/heap_allocation.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(blog_os::test_runner)]
#![reexport_test_harness_main = "test_main"]
extern crate alloc;
use bootloader::{entry_point, BootInfo};
use core::panic::PanicInfo;
entry_point!(main);
fn main(boot_info: &'static BootInfo) -> ! {
unimplemented!();
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}
```
`lib.rs``test_runner`関数と`test_panic_handler`関数を再利用します。私たちはアロケーションをテストしたいので、`extern crate alloc`宣言を使って`alloc`クレートを有効化します。テストに共通する定型部については[テスト][_Testing_]の記事を読んでください。
[_Testing_]: @/edition-2/posts/04-testing/index.ja.md
`main`関数の実装は以下のようになります:
```rust
// in tests/heap_allocation.rs
fn main(boot_info: &'static BootInfo) -> ! {
use blog_os::allocator;
use blog_os::memory::{self, BootInfoFrameAllocator};
use x86_64::VirtAddr;
blog_os::init();
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
let mut mapper = unsafe { memory::init(phys_mem_offset) };
let mut frame_allocator = unsafe {
BootInfoFrameAllocator::init(&boot_info.memory_map)
};
allocator::init_heap(&mut mapper, &mut frame_allocator)
.expect("heap initialization failed");
test_main();
loop {}
}
```
私たちの`main.rs`内の`kernel_main`関数によく似ていますが、`println`を呼び出さず、例示のため行ったアロケーションも行わず、また`test_main`を無条件で呼び出しているという違いがあります。
これでテストケースを追加する準備ができました。まず、[`Box`]を使って単純な<ruby>割り当て<rp> (</rp><rt>アロケーション</rt><rp>) </rp></ruby>を行い、割り当てられた値を確かめることで基本的なアロケーションがうまくいっていることを確かめるテストを追加しましょう:
```rust
// in tests/heap_allocation.rs
use alloc::boxed::Box;
#[test_case]
fn simple_allocation() {
let heap_value_1 = Box::new(41);
let heap_value_2 = Box::new(13);
assert_eq!(*heap_value_1, 41);
assert_eq!(*heap_value_2, 13);
}
```
最も重要なのは、このテストはアロケーションエラーが起きないことを検証してくれるということです。
次に、反復によって少しずつ大きなベクタを作ることで、大きな割り当てと(再割り当てによる)複数回の割り当ての両方をテストしましょう:
```rust
// in tests/heap_allocation.rs
use alloc::vec::Vec;
#[test_case]
fn large_vec() {
let n = 1000;
let mut vec = Vec::new();
for i in 0..n {
vec.push(i);
}
assert_eq!(vec.iter().sum::<u64>(), (n - 1) * n / 2);
}
```
このベクタの和を[n次部分和][n-th partial sum]の公式と比較することで検証しています。これにより、割り当てられた値はすべて正しいことをある程度保証できます。
[n-th partial sum]: https://ja.wikipedia.org/wiki/1%2B2%2B3%2B4%2B%E2%80%A6
3つ目のテストとして、10000回次々にアロケーションを行います
```rust
// in tests/heap_allocation.rs
use blog_os::allocator::HEAP_SIZE;
#[test_case]
fn many_boxes() {
for i in 0..HEAP_SIZE {
let x = Box::new(i);
assert_eq!(*x, i);
}
}
```
このテストではアロケータが解放されたメモリを次の割り当てで再利用していることを保証してくれます。もしそうなっていなければメモリ不足が起きるでしょう。こんなことアロケータにとって当たり前の要件だと思われるかもしれませんが、これを行わないようなアロケータの設計も存在するのです。その例として、次の記事で説明するbump allocatorがあります。
では、私たちの新しい結合テストを実行してみましょう:
```
> cargo test --test heap_allocation
[…]
Running 3 tests
simple_allocation... [ok]
large_vec... [ok]
many_boxes... [ok]
```
すべてのテストが成功しました!`cargo test`コマンドを(`--test`引数なしに)呼ぶことで、すべての結合テストを実行することもできます。
## まとめ
この記事では動的メモリに入門し、なぜ、そしていつそれが必要になるのかを説明しました。Rustの借用チェッカがどのようにしてよくある脆弱性を防ぐのか、そしてRustのアロケーションAPIがどのような仕組みなのかを理解しました。
ダミーアロケータでRustのアロケータインターフェースの最小限の実装を作成した後、私たちのカーネル用の適切なヒープメモリ領域を作成しました。これを行うために、ヒープ用の仮想アドレス範囲を定義し、前の記事で説明した`Mapper``FrameAllocator`を使ってその範囲のすべてのページを物理フレームに対応付けました。
最後に、`linked_list_allocator`クレートへの依存関係を追加し、適切なアロケータを私たちのカーネルに追加しました。このアロケータのおかげで、`alloc`クレートに含まれる`Box``Vec`、その他のアロケーション・コレクション型を使えるようになりました。
## 次は?
この記事ではヒープ割り当て機能のサポートを追加しましたが、ほとんどの仕事は`linked_list_allocator`クレートに任せてしまっています。次の記事では、アロケータをゼロから実装する方法を詳細にお伝えします。可能なアロケータの設計を複数提示し、それらを単純化したものを実装する方法を示し、それらの利点と欠点を説明します。

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1 +0,0 @@
<mxfile host="Electron" modified="2020-03-20T10:35:53.357Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/12.6.5 Chrome/80.0.3987.86 Electron/8.0.0 Safari/537.36" etag="GYNOQZMQxo_zCMLLT_wk" version="12.6.5" type="device"><diagram id="zeHyu60-730WxqfhUfuk" name="Page-1">7Vldb9sgFP01fmzk7ySPbdKt0zqpayate6Q2sVGJsTBukv76QQw22Plam6ZWNVeq4HC5wLk395jE8iaL1VcK8vQHiSG2XDteWd7Ucl1v7PP/AlhXgBu6FZBQFFeQ0wAz9AIlaEu0RDEsDENGCGYoN8GIZBmMmIEBSsnSNJsTbK6agwR2gFkEcBf9jWKWVugosBv8BqIkVSs7thxZAGUsgSIFMVlqkHdteRNKCKtai9UEYsGd4qWa92XHaL0xCjN2zITZC/aTcgZ+4zF5CFfpzfDevZBengEu5YHlZtlaMUBJmcVQOLEt72qZIgZnOYjE6JKHnGMpW2Dec3hTuoOUwdXOfTr16XnWQLKAjK65iZwwknzJhAlld9mwrxhNNeIVBmS8k9pvQwlvSFb+gSG3fwzVOdYTirweUuT2iyK/hxT5/aIo6CFFYb8oCk9M0RxhPCGY0M1cbz6HYRRxvGCUPEFtxN48JyK1ZwV++BlIdXsmCaNPQWrPRGTcIZW/qGYRf+Xm6M8ScrRNMj88M5k0GctIBlv0SghglGS8G3GyIMevBJWIvxhfyoEFimOxzNbQmcE9QSxaRcPvhqIu1meJhbqfaMH4DtePBNCYo98EZ7TMGW/fgIzzRPfkv3M4/981z4cmt/W7k0bucAu34btxe8StBGbxpbjeiQzFoChQZBIGV4g9yPQT7T+iPQhkb7rShqZr1cn47h/0jjZLdJtpm56aV20Oxp2bZCsA/ACkpBE8Iq0YoAlk+wy3R1QLWbAlZAqjEAOGns39boujXOGOoIzpamMkTO1WeaiOKSfpV9KWn1bi1SKm/FQsdPzwwIO1ZpYLg2L3doNWfgf23l21zIeGNW9UyzfpXdP/hozv3jLzskhFxtQV/mBlVxUbwznbV68pLNALeNw4Eukr6eNegysrmHIEg0eIr0D0lGyKlCG84lEmd6RADBHhnVZJVq962xqvVy94jUNZcrvZ5NQT4kMypi3hT8Xfvsomv+uRR7DqdNQ/cHsKy846eGEPvDAcmslykg+MY/i8cEeDkTfWntB0SObzArJW2p0m0bp3dU22foHiqcc61b45fLxQHXGtP1aonNcIlfNhQjU6Vqe8/0J1tFC11xnuF6rOi1twDqnqfk+Tk/x1StUWjfNIVUsfDynVvczKM0uVf0iq3MAUFVWM3ipVvplTzsAdv12deLf5paMyb34u8q7/Ag==</diagram></mxfile>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -1,7 +0,0 @@
+++
title = "Posts"
sort_by = "weight"
insert_anchor_links = "left"
render = false
page_template = "edition-2/page.html"
+++

View File

@@ -0,0 +1,4 @@
+++
title = "First Edition"
template = "first-edition/index.html"
+++

View File

@@ -4,7 +4,7 @@ weight = 1
path = "catching-exceptions"
aliases = ["catching-exceptions.html"]
date = 2016-05-28
template = "edition-1/page.html"
template = "first-edition/page.html"
[extra]
updated = "2016-06-25"
+++
@@ -20,8 +20,8 @@ As always, the complete source code is on [GitHub]. Please file [issues] for any
> **Note**: This post describes how to handle exceptions using naked functions (see [“Handling Exceptions with Naked Functions”] for an overview). Our new way of handling exceptions can be found in the [“Handling Exceptions”] post.
[“Handling Exceptions with Naked Functions”]: @/edition-1/extra/naked-exceptions/_index.md
[“Handling Exceptions”]: @/edition-1/posts/09-handling-exceptions/index.md
[“Handling Exceptions with Naked Functions”]: @/first-edition/extra/naked-exceptions/_index.md
[“Handling Exceptions”]: @/first-edition/posts/09-handling-exceptions/index.md
## Exceptions
An exception signals that something is wrong with the current instruction. For example, the CPU issues an exception if the current instruction tries to divide by 0. When an exception occurs, the CPU interrupts its current work and immediately calls a specific exception handler function, depending on the exception type.
@@ -35,7 +35,7 @@ We've already seen several types of exceptions in our kernel:
For the full list of exceptions check out the [OSDev wiki][exceptions].
[exceptions]: https://wiki.osdev.org/Exceptions
[exceptions]: http://wiki.osdev.org/Exceptions
### The Interrupt Descriptor Table
In order to catch and handle exceptions, we have to set up a so-called _Interrupt Descriptor Table_ (IDT). In this table we can specify a handler function for each CPU exception. The hardware uses this table directly, so we need to follow a predefined format. Each entry must have the following 16-byte structure:
@@ -245,7 +245,7 @@ It needs to be a function with a defined [calling convention], as it called dire
It is important that the function is [diverging], i.e. it must never return. The reason is that the hardware doesn't _call_ the handler functions, it just _jumps_ to them after pushing some values to the stack. So our stack might look different:
[diverging]: https://doc.rust-lang.org/rust-by-example/fn/diverging.html
[diverging]: https://doc.rust-lang.org/book/functions.html#diverging-functions
![normal function return vs interrupt function return](normal-vs-interrupt-function-return.svg)
@@ -311,8 +311,8 @@ u64 | Offset | Virtual start address of the table.
This structure is already contained [in the x86_64 crate], so we don't need to create it ourselves. The same is true for the [lidt function]. So we just need to put the pieces together to create a `load` method:
[in the x86_64 crate]: https://docs.rs/x86_64/0.1.0/x86_64/instructions/tables/struct.DescriptorTablePointer.html
[lidt function]: https://docs.rs/x86_64/0.1.0/x86_64/instructions/tables/fn.lidt.html
[in the x86_64 crate]: http://docs.rs/x86_64/0.1.0/x86_64/instructions/tables/struct.DescriptorTablePointer.html
[lidt function]: http://docs.rs/x86_64/0.1.0/x86_64/instructions/tables/fn.lidt.html
```rust
impl Idt {
@@ -422,7 +422,7 @@ extern "C" fn divide_by_zero_handler() -> ! {
```
We register a single handler function for a [divide by zero error] \(index 0). Like the name says, this exception occurs when dividing a number by 0. Thus we have an easy way to test our new exception handler.
[divide by zero error]: https://wiki.osdev.org/Exceptions#Division_Error
[divide by zero error]: http://wiki.osdev.org/Exceptions#Divide-by-zero_Error
However, it doesn't work this way:

View File

@@ -4,7 +4,7 @@ weight = 2
path = "better-exception-messages"
aliases = ["better-exception-messages.html"]
date = 2016-08-03
template = "edition-1/page.html"
template = "first-edition/page.html"
[extra]
updated = "2016-11-01"
+++
@@ -21,8 +21,8 @@ As always, the complete source code is on [GitHub]. Please file [issues] for any
> **Note**: This post describes how to handle exceptions using naked functions (see [“Handling Exceptions with Naked Functions”] for an overview). Our new way of handling exceptions can be found in the [“Handling Exceptions”] post.
[“Handling Exceptions with Naked Functions”]: @/edition-1/extra/naked-exceptions/_index.md
[“Handling Exceptions”]: @/edition-1/posts/09-handling-exceptions/index.md
[“Handling Exceptions with Naked Functions”]: @/first-edition/extra/naked-exceptions/_index.md
[“Handling Exceptions”]: @/first-edition/posts/09-handling-exceptions/index.md
## Exceptions in Detail
An exception signals that something is wrong with the currently-executed instruction. Whenever an exception occurs, the CPU interrupts its current work and starts an internal exception routine.
@@ -394,7 +394,7 @@ The base pointer is initialized directly from the stack pointer (`rsp`) after pu
The reason is that our exception handler is defined as `extern "C" function`, which specifies that it's using the C [calling convention]. On x86_64 Linux, the C calling convention is specified by the System V AMD64 ABI ([PDF][system v abi]). Section 3.2.2 defines the following:
[calling convention]: https://en.wikipedia.org/wiki/X86_calling_conventions
[system v abi]: https://web.archive.org/web/20160801075139/https://www.x86-64.org/documentation/abi.pdf
[system v abi]: http://web.archive.org/web/20160801075139/http://www.x86-64.org/documentation/abi.pdf
> The end of the input argument area shall be aligned on a 16 byte boundary. In other words, the value (%rsp + 8) is always a multiple of 16 when control is transferred to the function entry point.
@@ -628,7 +628,7 @@ bitflags! {
- When the `PROTECTION_VIOLATION` flag is set, the page fault was caused e.g. by a write to a read-only page. If it's not set, it was caused by accessing a non-present page.
- The `CAUSED_BY_WRITE` flag specifies if the fault was caused by a write (if set) or a read (if not set).
- The `USER_MODE` flag is set when the fault occurred in non-privileged mode.
- The `USER_MODE` flag is set when the fault occurred in non-priviledged mode.
- The `MALFORMED_TABLE` flag is set when the page table entry has a 1 in a reserved field.
- When the `INSTRUCTION_FETCH` flag is set, the page fault occurred while fetching the next instruction.

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