#!/usr/bin/env ruby # This file is part of INAV. # # author: Alberto Garcia Hierro # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at http://mozilla.org/MPL/2.0/. # # Alternatively, the contents of this file may be used under the terms # of the GNU General Public License Version 3, as described below: # # This file is free software: you may copy, redistribute and/or modify # it under the terms of the GNU General Public License as published by the # Free Software Foundation, either version 3 of the License, or (at your # option) any later version. # # This file is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see http://www.gnu.org/licenses/. require 'fileutils' require 'getoptlong' require 'json' require 'set' require 'stringio' require 'tmpdir' require 'yaml' require_relative 'compiler' DEBUG = false INFO = false SETTINGS_WORDS_BITS_PER_CHAR = 5 def dputs(s) puts s if DEBUG end def lputs(s) puts if DEBUG or INFO end class Object def is_number_kind? self.kind_of?(Integer) || self.kind_of?(Float) end end class String def is_number? true if Float(self) rescue false end end class StringIO def write_byte(b) self << [b].pack("C*") end def write_uvarint(x) while x >= 0x80 write_byte((x & 0xFF) | 0x80) x >>= 7 end write_byte(x) end def to_carr return string.bytes.to_s.sub('[', '{').sub(']', '}') end end class NameEncoder attr_reader :max_length attr_reader :max_word_length def initialize(names, max_length) @names = names @max_length = max_length # Key is word, value is number of uses @words = Hash.new(0) # Most used words first @words_by_usage = [] # Words that shouldn't be split because # their encoding is too long @non_split = Set.new # Key is the name, value is its encoding @encoded = Hash.new @max_word_length = 0; update_words encode_names end def uses_byte_indexing @words.length < 255 end def words @words_by_usage end def estimated_size(settings_count) size = 0 @max_word_length = 0 @words.each do |word, count| size += (word.length + 1) * (5/8.0) if word.length > @max_word_length @max_word_length = word.length end end return size.to_i + @max_length * settings_count end def format_encoded_name(name) encoded = @encoded[name] raise "Name #{name} was not encoded" if encoded == nil return encoded.to_carr end private def split_words(name) if @non_split.include?(name) return [name] end return name.split('_') end def update_words @words.clear @names.each do |name| split_words(name).each do |word| @words[word] += 1 end end # Sort by usage, then alphabetically @words_by_usage = @words.keys().sort do |x, y| ux = @words[x] uy = @words[y] if ux != uy uy <=> ux else x <=> y end end end def encode_names @encoded.clear() @names.each do |name| buf = StringIO.new split_words(name).each do |word| pos = @words_by_usage.find_index(word) raise "Word #{word} not found in words array" if pos == nil # Zero indicates end of words, first word in # the array starts at 1 ([0] is NULL). p = pos + 1 if uses_byte_indexing buf.write_byte(p) else buf.write_uvarint(p) end end if buf.length > @max_length # TODO: print in verbose mode # fmt.Printf("encoding %q took %d bytes (>%d), adding it as single word\n", v, len(data), e.MaxEncodedLength) @non_split << name update_words return encode_names end while buf.length < @max_length buf.write_byte(0) end @encoded[name] = buf end end end class ValueEncoder attr_reader :values def initialize(values, constants) min = 0 max = 0 valuesHash = Hash.new(0) values.each do |v| min = [min, v].min max = [max, v].max valuesHash[v] += 1 end # Sorted by usage, most used first @values = valuesHash.keys().sort do |x, y| ux = valuesHash[x] uy = valuesHash[y] if ux != uy uy <=> ux else x <=> y end end @constants = constants @min = min @max = max end def min_type [8, 16, 32].each do |x| if @min >= -(2**(x-1)) return "int#{x}_t" end end raise "cannot represent minimum value #{@min} with int32_t" end def max_type [8, 16, 32].each do |x| if @max < 2**x return "uint#{x}_t" end end raise "cannot represent maximum value #{@max} with uint32_t" end def index_bytes bits = Math.log2(@values.length).ceil bytes = (bits / 8.0).ceil.to_i if bytes > 1 raise "too many bytes required for value index: #{bytes}" end return bytes end def encode_values(min, max) buf = StringIO.new encode_value(buf, min) encode_value(buf, max) return buf.to_carr end def resolve_value(val) v = val || 0 if !v.is_number_kind? v = @constants[val] if v == nil raise "Could not resolve constant #{val}" end end return v end private def encode_value(buf, val) v = resolve_value(val) pos = @values.find_index(v) if pos < 0 raise "Could not encode value not in array #{v}" end buf.write_byte(pos) end end OFF_ON_TABLE = Hash["name" => "off_on", "values" => ["OFF", "ON"]] class Generator def initialize(src_root, settings_file, output_dir) @src_root = src_root @settings_file = settings_file @output_dir = output_dir || File.dirname(settings_file) @compiler = Compiler.new @count = 0 @max_name_length = 0 @tables = Hash.new @used_tables = Set.new @enabled_tables = Set.new end def write_files # Remove the old files first, if any [header_file, impl_file].each do |file| if File.file?(file) File.delete(file) end end load_data sanitize_fields initialize_name_encoder initialize_value_encoder write_header_file(header_file) write_impl_file(impl_file) end def write_json(jsonFile) load_data sanitize_fields(true) settings = Hash.new foreach_member do |group, member| name = member["name"] s = { "type" => member["type"], } table = member["table"] if table s["table"] = @tables[table] end settings[name] = s end File.open(jsonFile, "w") do |f| f.write(JSON.pretty_generate(settings)) end end def print_stats puts "#{@count} settings" puts "words table has #{@name_encoder.words.length} words" word_idx = @name_encoder.uses_byte_indexing ? "byte" : "uvarint" puts "name encoder uses #{word_idx} word indexing" puts "each setting name uses #{@name_encoder.max_length} bytes" puts "#{@name_encoder.estimated_size(@count)} bytes estimated for setting name storage" values_size = @value_encoder.values.length * 4 puts "min/max value storage uses #{values_size} bytes" value_idx_size = @value_encoder.index_bytes * 2 value_idx_total = value_idx_size * @count puts "value indexing uses #{value_idx_size} per setting, #{value_idx_total} bytes total" puts "#{value_idx_size+value_idx_total} bytes estimated for value+indexes storage" buf = StringIO.new buf << "#include \"fc/settings.h\"\n" buf << "char (*dummy)[sizeof(setting_t)] = 1;\n" stderr = compile_test_file(buf) puts "sizeof(setting_t) = #{/char \(\*\)\[(\d+)\]/.match(stderr)[1]}" end private def load_data @data = YAML.load_file(@settings_file) initialize_tables check_conditions end def header_file File.join(@output_dir, "settings_generated.h") end def impl_file File.join(@output_dir, "settings_generated.c") end def write_file_header(buf) buf << "// This file has been automatically generated by utils/settings.rb\n" buf << "// Don't make any modifications to it. They will be lost.\n\n" end def write_header_file(file) buf = StringIO.new write_file_header(buf) buf << "#pragma once\n" # Write setting_t size constants buf << "#define SETTING_MAX_NAME_LENGTH #{@max_name_length+1}\n" # +1 for the terminating '\0' buf << "#define SETTING_MAX_WORD_LENGTH #{@name_encoder.max_word_length+1}\n" # +1 for the terminating '\0' buf << "#define SETTING_ENCODED_NAME_MAX_BYTES #{@name_encoder.max_length}\n" if @name_encoder.uses_byte_indexing buf << "#define SETTING_ENCODED_NAME_USES_BYTE_INDEXING\n" end buf << "#define SETTINGS_WORDS_BITS_PER_CHAR #{SETTINGS_WORDS_BITS_PER_CHAR}\n" buf << "#define SETTINGS_TABLE_COUNT #{@count}\n" offset_type = "uint16_t" if can_use_byte_offsetof offset_type = "uint8_t" end buf << "typedef #{offset_type} setting_offset_t;\n" pgn_count = 0 foreach_enabled_group do |group| pgn_count += 1 end buf << "#define SETTINGS_PGN_COUNT #{pgn_count}\n" # Write type definitions and constants for min/max values buf << "typedef #{@value_encoder.min_type} setting_min_t;\n" buf << "typedef #{@value_encoder.max_type} setting_max_t;\n" buf << "#define SETTING_MIN_MAX_INDEX_BYTES #{@value_encoder.index_bytes*2}\n" # Write lookup table constants table_names = ordered_table_names() buf << "enum {\n" table_names.each do |name| buf << "\t#{table_constant_name(name)},\n" end buf << "\tLOOKUP_TABLE_COUNT,\n" buf << "};\n" # Write table pointers table_names.each do |name| buf << "extern const char * const #{table_variable_name(name)}[];\n" end # Write #define'd constants for referencing each setting ii = 0 foreach_enabled_member do |group, member| name = member["name"] min, max = resolve_range(member) setting_name = "SETTING_#{name.upcase}" buf << "#define #{setting_name} #{ii}\n" buf << "#define #{setting_name}_MIN #{min}\n" buf << "#define #{setting_name}_MAX #{max}\n" ii += 1 end File.open(file, 'w') {|file| file.write(buf.string)} end def write_impl_file(file) buf = StringIO.new write_file_header(buf) add_header = ->(h) { buf << "#include \"#{h}\"\n" } add_header.call("platform.h") add_header.call("config/parameter_group_ids.h") add_header.call("fc/settings.h") foreach_enabled_group do |group| (group["headers"] || []).each do |h| add_header.call(h) end end # Write PGN arrays pgn_steps = [] pgns = [] foreach_enabled_group do |group| count = 0 group["members"].each do |member| if is_condition_enabled(member["condition"]) count += 1 end end pgn_steps << count pgns << group["name"] end buf << "const pgn_t settingsPgn[] = {\n" pgns.each do |p| buf << "\t#{p},\n" end buf << "};\n" buf << "const uint8_t settingsPgnCounts[] = {\n" pgn_steps.each do |s| buf << "\t#{s},\n" end buf << "};\n" # Write word list buf << "static const uint8_t settingNamesWords[] = {\n" word_bits = SETTINGS_WORDS_BITS_PER_CHAR # We need 27 symbols for a-z + null rem_symbols = 2 ** word_bits - 27 symbols = Array.new acc = 0 acc_bits = 0 encode_byte = lambda do |c| if c == 0 chr = 0 # XXX: Remove this if we go for explicit lengths elsif c >= 'a'.ord && c <= 'z'.ord chr = 1 + (c - 'a'.ord) elsif c >= 'A'.ord && c <= 'Z'.ord raise "Cannot encode uppercase character #{c.ord} (#{c})" else idx = symbols.index(c) if idx.nil? if rem_symbols == 0 raise "Cannot encode character #{c.ord} (#{c}), no symbols remaining" end rem_symbols -= 1 idx = symbols.length symbols.push(c) end chr = 1 + ('z'.ord - 'a'.ord + 1) + idx end if acc_bits >= (8 - word_bits) # Write remaining = 8 - acc_bits acc |= chr << (remaining - word_bits) buf << "0x#{acc.to_s(16)}," acc = (chr << (8 - (word_bits - remaining))) & 0xff else # Accumulate for next byte acc |= chr << (3 - acc_bits) end acc_bits = (acc_bits + word_bits) % 8 end @name_encoder.words.each do |w| buf << "\t" w.each_byte {|c| encode_byte.call(c)} encode_byte.call(0) buf << " /* #{w.inspect} */ \n" end if acc_bits > 0 buf << "\t0x#{acc.to_s(16)}," if acc_bits > (8 - word_bits) buf << "0x00" end buf << "\n" end buf << "};\n" # Output symbol array buf << "static const char wordSymbols[] = {" symbols.each { |s| buf << "'#{s.chr}'," } buf << "};\n" # Write the tables table_names = ordered_table_names() table_names.each do |name| buf << "const char * const #{table_variable_name(name)}[] = {\n" tbl = @tables[name] tbl["values"].each do |v| buf << "\t#{v.inspect},\n" end buf << "};\n" end buf << "static const lookupTableEntry_t settingLookupTables[] = {\n" table_names.each do |name| vn = table_variable_name(name) buf << "\t{ #{vn}, sizeof(#{vn}) / sizeof(char*) },\n" end buf << "};\n" # Write min/max values table buf << "static const uint32_t settingMinMaxTable[] = {\n" @value_encoder.values.each do |v| buf << "\t#{v},\n" end buf << "};\n" case @value_encoder.index_bytes when 1 buf << "typedef uint8_t setting_min_max_idx_t;\n" buf << "#define SETTING_INDEXES_GET_MIN(val) (val->config.minmax.indexes[0])\n" buf << "#define SETTING_INDEXES_GET_MAX(val) (val->config.minmax.indexes[1])\n" else raise "can't encode indexed values requiring #{@value_encoder.index_bytes} bytes" end # Write setting_t values buf << "static const setting_t settingsTable[] = {\n" last_group = nil foreach_enabled_member do |group, member| if group != last_group last_group = group buf << "\t// #{group["name"]}\n" end name = member["name"] buf << "\t{ #{@name_encoder.format_encoded_name(name)}, " buf << "#{var_type(member["type"])} | #{value_type(group)}" tbl = member["table"] if tbl buf << " | MODE_LOOKUP" buf << ", .config.lookup = { #{table_constant_name(tbl)} }" else min, max = resolve_range(member) if min > max raise "Error encoding #{name}: min (#{min}) > max (#{max})" end enc = @value_encoder.encode_values(min, max) buf << ", .config.minmax.indexes = #{enc}" end buf << ", offsetof(#{group["type"]}, #{member["field"]}) },\n" end buf << "};\n" File.open(file, 'w') {|file| file.write(buf.string)} end def var_type(typ) case typ when "uint8_t", "bool" return "VAR_UINT8" when "int8_t" return "VAR_INT8" when "uint16_t" return "VAR_UINT16" when "int16_t" return "VAR_INT16" when "uint32_t" return "VAR_UINT32" when "float" return "VAR_FLOAT" when "string" return "VAR_STRING" else raise "unknown variable type #{typ.inspect}" end end def value_type(group) return group["value_type"] || "MASTER_VALUE" end def resolve_range(member) min = @value_encoder.resolve_value(member["min"]) max = @value_encoder.resolve_value(member["max"]) return min, max end def is_condition_enabled(cond) return !cond || @true_conditions.include?(cond) end def foreach_enabled_member @data["groups"].each do |group| if is_condition_enabled(group["condition"]) group["members"].each do |member| if is_condition_enabled(member["condition"]) yield group, member end end end end end def foreach_enabled_group last = nil foreach_enabled_member do |group, member| if last != group last = group yield group end end end def foreach_member @data["groups"].each do |group| group["members"].each do |member| yield group, member end end end def foreach_group last = nil foreach_member do |group, member| if last != group last = group yield group end end end def initialize_tables @data["tables"].each do |tbl| name = tbl["name"] if @tables.key?(name) raise "Duplicate table name #{name}" end @tables[name] = tbl end end def ordered_table_names @enabled_tables.to_a().sort() end def table_constant_name(name) upper = name.upcase() "TABLE_#{upper}" end def table_variable_name(name) "table_#{name}" end def can_use_byte_offsetof buf = StringIO.new foreach_enabled_member do |group, member| typ = group["type"] field = member["field"] buf << "static_assert(offsetof(#{typ}, #{field}) < 255, \"#{typ}.#{field} is too big\");\n" end stderr = compile_test_file(buf) return !stderr.include?("static assertion failed") end def mktmpdir # Use a temporary dir reachable by relative path # since g++ in cygwin fails to open files # with absolute paths tmp = File.join(@output_dir, "tmp") FileUtils.mkdir_p(tmp) unless File.directory?(tmp) value = yield(tmp) if File.directory?(tmp) FileUtils.remove_dir(tmp) end value end def compile_test_file(prog) buf = StringIO.new # cstddef for offsetof() headers = ["platform.h", "cstddef"] @data["groups"].each do |group| gh = group["headers"] if gh headers.concat gh end end headers.each do |h| if h buf << "#include \"#{h}\"\n" end end buf << "\n" buf << prog.string mktmpdir do |dir| file = File.join(dir, "test.cpp") File.open(file, 'w') {|file| file.write(buf.string)} dputs "Compiling #{buf.string}" stdout, stderr = @compiler.run(file, File.join(dir, "test"), '-c', noerror: true) dputs "Output: #{stderr}" stderr end end def check_conditions buf = StringIO.new conditions = Set.new add_condition = -> (c) { if c && !conditions.include?(c) conditions.add(c) buf << "#ifdef #{c}\n" buf << "#pragma message(#{c.inspect})\n" buf << "#endif\n" end } foreach_member do |group, member| add_condition.call(group["condition"]) add_condition.call(member["condition"]) end stderr = compile_test_file(buf) @true_conditions = Set.new stderr.scan(/#pragma message\(\"(.*)\"\)/).each do |m| @true_conditions << m[0] end end def sanitize_fields(all=false) pending_types = Hash.new has_booleans = false block = Proc.new do |group, member| if !group["name"] raise "Missing group name" end if !member["name"] raise "Missing member name in group #{group["name"]}" end table = member["table"] if table if !@tables[table] raise "Member #{member["name"]} references non-existing table #{table}" end @used_tables << table end if !member["field"] member["field"] = member["name"] end typ = member["type"] if !typ pending_types[member] = group elsif typ == "bool" has_booleans = true member["type"] = "uint8_t" member["table"] = OFF_ON_TABLE["name"] end end all ? foreach_member(&block) : foreach_enabled_member(&block) if has_booleans @tables[OFF_ON_TABLE["name"]] = OFF_ON_TABLE @used_tables << OFF_ON_TABLE["name"] end resolve_types pending_types unless !pending_types foreach_enabled_member do |group, member| @count += 1 @max_name_length = [@max_name_length, member["name"].length].max if member["table"] @enabled_tables << member["table"] end end end def resolve_types(pending) # TODO: Loop to avoid reaching the maximum number # of errors printed by the compiler. prog = StringIO.new prog << "int main() {\n" ii = 0 members = Hash.new pending.each do |member, group| var = "var_#{ii}" members[ii] = member ii += 1 gt = group["type"] mf = member["field"] prog << "#{gt} #{var}; #{var}.#{mf}.__type_detect_;\n" end prog << "return 0;\n" prog << "};\n" stderr = compile_test_file(prog) stderr.scan(/var_(\d+).*?', which is of non-class type '(.*)'/).each do |m| member = members[m[0].to_i] case m[1] when /^int8_t/ # {aka signed char}" typ = "int8_t" when /^uint8_t/ # {aka unsigned char}" typ = "uint8_t" when /^int16_t/ # {aka short int}" typ = "int16_t" when /^uint16_t/ # {aka short unsigned int}" typ = "uint16_t" when /^uint32_t/ # {aka long unsigned int}" typ = "uint32_t" when "float" typ = "float" when /^char \[(\d+)\]/ # Substract 1 to show the maximum string size without the null terminator member["max"] = $1.to_i - 1; typ = "string" else raise "Unknown type #{m[1]} when resolving type for setting #{member["name"]}" end dputs "#{member["name"]} type is #{typ}" member["type"] = typ end # Make sure all types have been resolved foreach_enabled_member do |group, member| if !member["type"] raise "Could not resolve type for member #{member["name"]} in group #{group["name"]}" end end end def initialize_name_encoder names = [] foreach_enabled_member do |group, member| names << member["name"] end best = nil (3..7).each do |v| enc = NameEncoder.new(names, v) if best == nil || best.estimated_size(@count) > enc.estimated_size(@count) best = enc end end dputs "Using name encoder with max_length = #{best.max_length}" @name_encoder = best end def initialize_value_encoder values = [] constants = [] add_value = -> (v) { v = v || 0 if v.is_number_kind? || (v.class == String && v.is_number?) values << v.to_i else constants << v end } foreach_enabled_member do |group, member| add_value.call(member["min"]) add_value.call(member["max"]) end constantValues = resolve_constants(constants) # Count values used by constants constants.each do |c| values << constantValues[c] end @value_encoder = ValueEncoder.new(values, constantValues) end def resolve_constants(constants) return nil unless constants.length > 0 s = Set.new constants.each do |c| s << c end dputs "#{constants.length} constants to resolve" # Since we're relying on errors rather than # warnings to find these constants, the compiler # might reach the maximum number of errors and stop # compilation, so we might need multiple passes. re = /required from 'class expr_(.*?)<(.*)>'/ values = Hash.new while s.length > 0 buf = StringIO.new buf << "template class Fail {\n" # Include V in the static_assert so it's shown # in the error condition. buf << "static_assert(V == 42 && 0 == 1, \"FAIL\");\n" buf << "public:\n" buf << "Fail() {};\n" buf << "int64_t v = V;\n" buf << "};\n" ii = 0 s.each do |c| cls = "expr_#{c}" buf << "template class #{cls}: public Fail {};\n" buf << "#{cls}<#{c}> var_#{ii};\n" ii += 1 end stderr = compile_test_file(buf) matches = stderr.scan(re) if matches.length == 0 puts stderr raise "No more matches looking for constants" end matches.each do |m| c = m[0] v = m[1] # gcc 6.3 includes an ul or ll prefix after the # constant expansion, while gcc 7.1 does not v = v.tr("ul", "") nv = v.to_i values[c] = nv s.delete(c) dputs "Constant #{c} resolved to #{nv}" end end values end end def usage puts "Usage: ruby #{__FILE__} [--json ]" end if __FILE__ == $0 verbose = ENV["V"] == "1" src_root = ARGV[0] settings_file = ARGV[1] if src_root.nil? || settings_file.nil? usage() exit(1) end opts = GetoptLong.new( [ "--output-dir", "-o", GetoptLong::REQUIRED_ARGUMENT ], [ "--help", "-h", GetoptLong::NO_ARGUMENT ], [ "--json", "-j", GetoptLong::REQUIRED_ARGUMENT ], ) jsonFile = nil output_dir = nil opts.each do |opt, arg| case opt when "--output-dir" output_dir = arg when "--help" usage() exit(0) when "--json" jsonFile = arg end end gen = Generator.new(src_root, settings_file, output_dir) if jsonFile gen.write_json(jsonFile) else gen.write_files() if verbose gen.print_stats() end end end