nativeos/tools/kcons

610 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0
"""
# This file is part of NativeOS
# Copyright (C) 2021 The NativeOS contributors
kcons is the kernel construction tool. kcons is heavily inspired by config(1),
config(8) and other similar tools present in many UNIX systems starting from
the original BSD 4.2 UNIX and nowadays on other BSD operating systems such
as FreeBSD or NetBSD.
However, this tool is called kcons and not config, because it is not 100%
compatible, since I have my own plans and needs based on my project. Some of
the most notable changes:
* I don't split the config files in multiple directories. Profiles, templates
and file lists should be present in the same conf/ directory.
"""
import os
import shlex
import sys
MAKEFILE_RULE_TEMPLATE = "".join(
[
"%%object%%: $(ROOT)/%%source%%\n"
"\t$(CC) $(CFLAGS) $(%%object%%_CFLAGS) $(ROOT)/%%source%%\n",
]
)
class ConfigDumper:
"""
The Config dumper is the class that generates the config.h file. It
is generated into the compile directory with this name, so that it can
be imported when compiling the kernel.
"""
def __init__(self, context):
self.context = context
self.config_dest = os.path.join(context.compile_dir, "config.h")
def dump(self):
self.dump_config_file()
def dump_config_file(self):
with open(self.config_dest, "w") as config:
config.write("#pragma once\n")
config.write("\n")
config.write("// This file was generated by kcons\n\n")
for name, value in self.context.profile.defines.items():
line = f"#define {name} {value}"
config.write(line.strip() + "\n")
class MakefileDumper:
"""
The Makefile dumper is the class that generates the Makefile using the
templates and the data currently held by the context. The template is
a file that must be present in the config directory and it is a file
with the name Makefile.{arch}, being arch the system architecture, such
as Makefile.i386 or Makefile.aarch64.
The Makefile will be copied into the compile/{profile} directory in the
root, such as $ROOT/compile/I386, but some special commands present in
the template file will be replaced by the context state.
For a command to be recognized and expanded, it must be the only contents
of that specific line. Lines that do not match with a command are copied
raw from the template into the destination file.
The detected commands are:
%%MAKEOPTIONS
Will be expanded into variable declarations for every makeoption
previously found in the profile file. Note that the output
depends on whether the variable was set or appended.
%%OBJS
Wil be expanded into the declaration of a variable called OBJS where
every object file that will be included in the kernel image is
present.
%%RULES
Will be expanded into a Makefile rule to build every source
object that has been previously caught by reading and filtering
the files list.
"""
def __init__(self, context):
self.context = context
makefile_orig_path = "Makefile." + context.profile.arch
self.makefile_orig = os.path.join(context.conf_dir, makefile_orig_path)
self.makefile_dest = os.path.join(context.compile_dir, "Makefile")
def object_file_for(self, name):
"""
Given the name of a source file, this function will return the name
of the object file that should be used to compile this file.
"""
base_name = os.path.basename(name)
name, ext = os.path.splitext(base_name)
return f"{name}.o"
def replace_all(self, str, tokens):
for find, repl in tokens.items():
str = str.replace(find, repl)
return str
def dump(self):
with open(self.makefile_dest, "w") as makefile:
makefile_lines = open(self.makefile_orig).readlines()
for line in makefile_lines:
clean_line = line.strip()
if clean_line == "%%MAKEOPTIONS":
self.expand_makeopts(makefile)
elif clean_line == "%%OBJS":
self.expand_objs(makefile)
elif clean_line == "%%SRCS":
self.expand_srcs(makefile)
elif clean_line == "%%RULES":
self.expand_rules(makefile)
else:
makefile.write(line)
def expand_makeopts(self, file):
options = []
for name, value in self.context.profile.makeoptions.items():
options.append(f"{name}={value}")
for name, value in self.context.profile.appendmakeoptions.items():
options.append(f"{name}+={value}")
options.sort()
file.write("\n".join(options) + "\n")
def expand_list_of_files(self, file, preffix, items):
buffer = preffix
for item in items:
before_buffer = buffer + " " + item
if len(before_buffer) > 60:
# Escape a line break to avoid making the line very long
buffer += "\\\n"
file.write(buffer)
buffer = "\t" + item + " "
else:
buffer += item + " "
# Remove trailing space.
if buffer.endswith(" "):
buffer = buffer[:-1]
file.write(buffer)
file.write("\n")
def expand_srcs(self, file):
sources = ["".join(["$(ROOT)/", s]) for s in self.context.file_list]
self.expand_list_of_files(file, "SRCS = \t", sources)
def expand_objs(self, file):
objects = [self.object_file_for(s) for s in self.context.file_list]
self.expand_list_of_files(file, "OBJS = \t", objects)
def expand_rules(self, file):
for source_file in self.context.file_list:
object_file = self.object_file_for(source_file)
rule = self.replace_all(
MAKEFILE_RULE_TEMPLATE,
{
"%%source%%": source_file,
"%%object%%": object_file,
},
)
file.write(rule)
class Parser:
"""
The base parser is able to read the contents of files and perform a basic
and probably dangerous tokenization by using splits and other string
manipulation functions.
There are different kinds of files, with different subgrammars, and each
kind will require its own parser, but every parser has some common cases:
* Every kind of file uses # as a comment separator. Comments always come
afte a whitespace character such as a space, a tab or a line break
character (or either they are the first character of the file). What I
mean by this is that you can't just parse "foo#bar" as "foo" with "bar"
as a comment.
* Files are made of directives, such as compilation directives or file
inclusion directives. A directive is a set of words, paths or tokens
that set the behaviour of the execution context. There is up to one
directive per line. It is not possible to have two directives in the
same line, but it is possible to have a directive split among multiple
lines by escaping the \n or \r\n character.
"""
def __init__(self, file):
self.lines = open(file).readlines()
def parse(self):
slash_buffer = ""
for raw_line in self.lines:
# Leading and trailing whitespaces are omitted. (In fact, this is
# a good moment to normalize all whitespaces into single spaces.)
raw_line = raw_line.strip()
# Detect if this line will require to be merged with the next one.
if raw_line.endswith("\\"):
slash_buffer = raw_line[:-1]
continue
# Detect whether we come from a merged line.
if slash_buffer:
raw_line = slash_buffer + raw_line
slash_buffer = ""
# Delegate in shlex the parsing of the line. shlex will take care
# of escaping characters in front of a whitespace (such as a slash
# before a space or a tab token). Also, it can trim whitespace.
# It is much better than doing .split() and performing the escaping
# manually.
split_line = shlex.split(raw_line, comments="#")
if split_line:
yield split_line
class FileListParser(Parser):
"""
A file list is a kind of file that contains directives to build list of
files. A directive tells kcons about a file that has to be included when
compiling the kernel.
A file may be standard, in which case it is always included, or may be
optional. If a file is optional, it has a list of options. The file is only
included if the option is enabled in the profile for that specific kernel.
For example:
kernel/mutex.c standard
kernel/spinlock.c standard
kernel/vgacon.c optional console
kernel/uart8250.c optional uart ibm
kernel/pl011.c optional uart rpi
In this case:
* kernel/mutex.c and kernel/spinlock.c are always picked.
* kernel/vgacon.c will only be picked if the kernel profile has the
`console` option added.
* kernel/uart8250.c requires both `uart` and `ibm` options picked. Having
only `uart` or `ibm` without the other one not enough.
* Same goes for kernel/pl011.c, which requires both `uart` and `rpi` set.
"""
def __init__(self, file):
super().__init__(file)
self.files = {}
self.errors = []
def process(self):
"""
Process the list of files in this file in order to build the list.
This method has to be called once in order to populate the self.files
data structure and possibly the self.errors in case of parse errors.
"""
for file, kind, *options in self.parse():
if kind not in ["standard", "optional"]:
# Given kind is not acceptable.
self.errors.append(f"Invalid file kind {kind} for file {file}")
elif kind == "standard":
# The file is standard, it always has to be included.
self.files[file] = {"standard": True}
elif not options:
# The file is optional, but no options were given.
self.errors.append(f"No options given for optional file {file}")
else:
# The file is optional and options were given.
self.files[file] = {"standard": False, "options": options}
def compilable_files(self, profile):
"""
Given a profile, it will return a slice of all the files in the parsed
list that match are, either standard, or optional having the required
options being set in the profile.
"""
def matches(options):
# Returns true if every item in the options iterable is also
# present in the options for the profile bound to compilable_files.
return all([opt in profile.options for opt in options])
return [
file
for file, config in self.files.items()
if config["standard"] or matches(config["options"])
]
class ProfileParser(Parser):
"""
Parses a profile file. A profile file contains the configuration of a
specific kernel image compilation. For instance, it sets the build options,
which may translate in additional header files or specific C macros being
set.
It is a file where each line contains one of the following directives:
arch [architecture]
The system architecture to build. This affects the template Makefile
to be used and also causes kcons to include an addition of another
files list.
define [ (NAME | NAME=value) ...]
A define is translated into a C preprocessor macro. When kcons is
generating the compile directory, the final list of defined macros
is written into a file called config.h, placed in the compile
directory.
There are two ways to declare a definition
The first form is specifying the name of the definition, such as
define FOOBAR
This will roughly be translated into the equivalent C code that defines
a macro but does not set a value, such as:
#define FOOBAR
It is possible to provide a value to the definition using the second
form, such as
define FOOBAR=42
Which will be translated into the expected C code that defines a macro
and sets the value to the one given to the directive:
#define FOOBAR 42
makeoption [ (OPTION | OPTION=value | OPTION+=value) ...]
A makeoption is translated into a Makefile variable in the final
Makefile. This directive accepts multiple parameters and each
parameter is translated into a separate makeoption.
There are ways to declare an option.
The first form is simply specifying the name of the makeoption. This
will set a variable to an empty string, and may be enough if it
is possible to test for the presence of a variable regardless of
the value.
makeoption FOOBAR
Will generate the following Makefile snippet:
FOOBAR=
The second form sets the name of a makeoption to a specific value.
Any value previously set by another makeoption directive is
disregarded and not included in the final Makefile.
makeoption DEBUG=-g
makeoption FOOBAR=1
makeoption FOOBAR=2
Will generate the following Makefile snippet:
DEBUG=-g
FOOBAR=2
The third form appends a value to a variable. If the variable was
previously set by another makeoption directive, it will be appended
to the value previously set. If the variable was not set using a
makeoption directive, it will be added as an append. This is useful
to complete parameters set by the user when calling `make`.
makeoption CFLAGS+="-g -O0"
makeoption MODULES=" uart.o "
makeoption MODULES+=" framebuffer.o "
Will generate the following Makefile snippet:
CFLAGS+=-g -O0
MODULES=" uart.o framebuffer.o "
nodefine [names...]
For each parameter given to this directive, it will delete the define
set by the define directive if previously set, or ignored if it was
not provided.
nooption [names...]
For each parameter given to this directive, it disables the parameters
in the options set. If the option was already disabled or was never
enabled, the option will be ignored.
option [names...]
For each parameter given to this directive, it enables the parameters
in the options set. If the option was already enabled, the option
will be ignored.
"""
def __init__(self, file):
super().__init__(file)
self.arch = None
self.defines = {}
self.makeoptions = {}
self.appendmakeoptions = {}
self.options = set()
self.errors = []
def process(self):
"""
Parse the directives contained in the profile file. This function has
to be called once to set the state of the class depending on the
directives declared in the profile file. As a result, the errors list
will also be set to any error found while processing the directives.
"""
for cmd, *args in self.parse():
if cmd == "arch":
self.set_arch(args)
elif cmd == "define":
self.add_define(args)
elif cmd == "makeoption":
self.add_makeoption(args)
elif cmd == "nodefine":
self.remove_define(args)
elif cmd == "nooption":
self.remove_options(args)
elif cmd == "option":
self.add_options(args)
else:
self.errors.append(f"Unknown directive: {cmd}")
def set_arch(self, argv):
if len(argv) != 1:
self.errors.append(f"arch: invalid value")
return
self.arch = argv[0]
def add_options(self, options):
self.options.update(options)
def add_define(self, options):
for define in options:
# No equals sets the define to an empty string.
if "=" not in define:
self.defines[define] = ""
else:
name, value = define.split("=")
self.defines[name] = value
def add_makeoption(self, options):
for makeoption in options:
# No equal sets the variable to an empty string.
if "=" not in makeoption:
makeoption = makeoption + "="
if "+=" in makeoption:
name, value = makeoption.split("+=")
if name in self.makeoptions:
# The variable was set by another makeoption directive.
self.makeoptions[name] += value
else:
# If never seen before, it is an appendmakeoption.
self.appendmakeoptions[name] = value
else:
# It is an update. Forget about previous values.
name, value = makeoption.split("=")
self.makeoptions[name] = value
if name in self.appendmakeoptions:
del self.appendmakeoptions[name]
def remove_define(self, options):
for option in options:
del self.defines[option]
def remove_options(self, options):
self.options.difference_update(options)
class Context:
"""
The context is the main driver that will find the files associated to a
profile file (such as the files or the Makefile) and process them at all
in order to create the build directory containing the final Makefile and
generated header files.
"""
def __init__(self, profile_path):
# Build the paths that we are going to require.
self.profile_file = self.normalize_file(profile_path)
self.profile_name = os.path.basename(self.profile_file)
self.conf_dir = os.path.dirname(self.profile_file)
self.root_dir = self.normalize_file("..", base=self.conf_dir)
compile_path = f"compile/{self.profile_name}"
self.compile_dir = os.path.join(self.root_dir, compile_path)
# Validate config. Won't return if config is invalid.
self.validate_context()
def validate_context(self):
"""
After setting up the context, check if its valid. For a context to
be valid, every file that will be touched must be readable and
accessible.
"""
# By validating the profile_name, we also validate the parent dirs.
if not os.path.exists(self.profile_file):
print(f"Error! {self.profile_file} not found", file=sys.stderr)
sys.exit(66) # EX_NOINPUT
def parse_profile(self):
"""
Reads the profile file and processes it using the profile parser.
If the profile parser is valid, the configuration will be stored so
that it can be reused later.
This function does not return if the profile file is not valid.
Instead, the list of errors produced while parsing the profile file
will be dumped to the output and the program will exit.
"""
self.profile = ProfileParser(self.profile_file)
self.profile.process()
if self.profile.errors:
print("Error! Parsing the profile failed", file=sys.stderr)
for error in self.profile.errors:
print(" ", error, file=sys.stderr)
sys.exit(65) # EX_DATAERR
def parse_files(self):
"""
Reads the file lists and processes them using the file list parser.
The files `files` and `files.{arch}` are always read. They both must
exist in the same directory as the profile file, and must be readable.
This function does not return if any of the file lists is not valid
due to a programming error. The list of errors in the last file that
was parsed will be printed to screen and the program will exit.
"""
def parse_file(path):
parser = FileListParser(path)
parser.process()
if parser.errors:
print(f"Error! Parsing {path}", file=sys.stderr)
for error in parser.errors:
print(" ", error, file=sys.stderr)
sys.exit(65) # EX_DATAERR
return parser
# The list of files will be stored in this set to prevent dupes.
file_set = set()
# Build the list of files to parse and parse them.
file_names = ["files", "files." + self.profile.arch]
for file_name in file_names:
file_path = self.normalize_file(file_name, base=self.conf_dir)
file_list = parse_file(file_path).compilable_files(self.profile)
file_set.update(file_list)
# Store them as a sorted list just to make the output more clear.
self.file_list = list(file_set)
self.file_list.sort()
def create_compile(self):
os.makedirs(self.compile_dir, exist_ok=True)
MakefileDumper(self).dump()
ConfigDumper(self).dump()
def try_create_machdep_references(self):
source_dir = os.path.join(self.root_dir, "kernel", self.profile.arch)
include_dir = os.path.join(source_dir, "include")
target_path = os.path.join(self.compile_dir, "machine")
if os.path.exists(include_dir):
# symlink will fail if the target location already exists
if os.path.exists(target_path):
os.unlink(target_path)
os.symlink(include_dir, target_path)
def normalize_file(self, path, base=None):
"""
Given a path that should map to a file, this function will normalize
the given path so that it is absolute. If the given path is already
absolute, this function does not do anything. The outcome of this
function depends on the given path and the PWD in use when calling
kcons.
"""
if os.path.isabs(path):
return path
else:
if not base:
base = os.getcwd()
return os.path.normpath(os.path.join(base, path))
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} [PROFILE]", file=sys.stderr)
sys.exit(64) # EX_USAGE
context = Context(sys.argv[1])
context.parse_profile()
context.parse_files()
context.create_compile()
context.try_create_machdep_references()