Something went wrong. Try again.
An assembler for the 6502 and 65SC02 written in Zig, in the dialect of Frank Kingswood's as65.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The `as65` command.
const std = @import("std");const Io = std.Io;const aigrette65 = @import("aigrette65");const banner = aigrette65.banner;const Options = aigrette65.Options;const Assembler = aigrette65.Assembler;const Image = aigrette65.Image;
/// The one-line usage, printed whenever the command line was no good.const usage_line = "Usage: " ++ banner.program ++ " [-DIcghilmnopqstuvwxz] file\n";
/// The rest of what `-?` prints.const option_list = "\nOptions:\n\t-D<name> : Define label, if no name given define DEBUG.\n\t-I<path> : Specify include file path string.\n\t-c : Show number of cycles per instruction in listing.\n\t-g : Generate source-level debug information.\n\t-h<num> : Specify height of page for listing.\n\t-i : Ignore case in opcodes.\n\t-l : Generate pass 2 listing.\n\t-l<name> : Listing file name.\n\t-m : Show macro expansions in listing.\n\t-n : Disable optimizations.\n\t-o<name> : Binary/hex output file name.\n\t-p : Generate pass 1 listing.\n\t-q : Quiet mode.\n\t-s1 -s : Write s-records instead of binary file.\n\t-s2 : Write intel-hex file instead of binary file.\n\t-t : Generate symbol table.\n\t-u : List memory usage map.\n\t-v : Verbose mode.\n\t-w<wid> : Specify listing width, 131 if no number given.\n\t-x1 : Use 6502 undefined instructions.\n\t-x2 -x : Use 65SC02 extensions.\n\t-z : Fill unused memory with zeros.\n";
/// The extensions tried in turn after the name as it was given, which is how/// `as65` finds `prog.a65` when told to assemble `prog`.const source_extensions = [_][]const u8{ ".a65", ".asm", ".s" };
/// Source files, read out of a directory.////// The library never opens a file: it asks for a name and is handed a reader/// or told there is nothing there. This is the command's answer, and the only/// place it reads input from.const Files = struct { io: Io, dir: Io.Dir, buffer: [4096]u8 = undefined, open_file: ?Io.File = null, reader: Io.File.Reader = undefined,
fn source(self: *Files) aigrette65.Source { return .{ .context = self, .open = open, .close = close }; }
fn open(context: ?*anyopaque, name: []const u8) aigrette65.Source.OpenError!?*Io.Reader { const self: *Files = @ptrCast(@alignCast(context.?)); const file = self.dir.openFile(self.io, name, .{}) catch return null; self.open_file = file; self.reader = .init(file, self.io, &self.buffer); return &self.reader.interface; }
fn close(context: ?*anyopaque, _: *Io.Reader) void { const self: *Files = @ptrCast(@alignCast(context.?)); if (self.open_file) |file| file.close(self.io); self.open_file = null; }
/// Whether a name can be opened at all, which is how the command finds /// out the source is missing before it has said anything. fn exists(self: *const Files, name: []const u8) bool { const file = self.dir.openFile(self.io, name, .{}) catch return false; file.close(self.io); return true; }};
/// The exit statuses `as65` documents.const Status = enum(u8) { ok = 0, bad_commandline = 1, file_error = 2, assembly_errors = 3,};
pub fn main(init: std.process.Init) !u8 { const gpa = init.gpa; const io = init.io; const arena = init.arena.allocator();
var stdout_buffer: [4096]u8 = undefined; var stdout_file: Io.File.Writer = .init(.stdout(), io, &stdout_buffer); const stdout = &stdout_file.interface; defer stdout.flush() catch {};
var stderr_buffer: [256]u8 = undefined; var stderr_file: Io.File.Writer = .init(.stderr(), io, &stderr_buffer); const stderr = &stderr_file.interface; defer stderr.flush() catch {};
const argv = try init.minimal.args.toSlice(arena); const args = if (argv.len > 1) argv[1..] else argv[0..0];
var diagnostic: Options.Diagnostic = .{}; const include_env = init.environ_map.get("INCLUDE"); const parsed = Options.parse(gpa, args, include_env, &diagnostic); // A page length that went missing is said before anything else, banner // included, and does not stop the run. if (diagnostic.page_length_missing) { try stdout.writeAll("Page length missing after -h.\n"); } var options = parsed catch |err| { switch (err) { // `-?` is the whole of the help. error.HelpRequested => { try stdout.print("{s}\n\n" ++ usage_line ++ option_list, .{banner.long}); }, // Nothing to assemble: the banner and how to say it properly. The // banner goes even when `-q` asked for silence, as as65's does. error.NoInputFile => { try stdout.print("{s}\n\n" ++ usage_line, .{banner.long}); }, error.UnknownOption => { try stdout.print("{s}\n\n{s}\n" ++ usage_line, .{ banner.long, diagnostic.text() }); }, // This one is said on its own, with no banner over it. error.MultipleInputFiles => try stdout.print("{s}\n", .{diagnostic.text()}), error.OutOfMemory => return err, } return @backingInt(Status.bad_commandline); }; defer options.deinit(gpa);
// The name the object and listing files are built from is the one that was // typed; the name the listing header and the diagnostics show is the file // that was actually found. var files = Files{ .io = io, .dir = Io.Dir.cwd() }; const base = stemOf(options.input); options.input = resolveSource(&files, arena, options.input) catch |err| switch (err) { error.FileNotFound => { try stdout.print("Can't open source file {s}{s}.\n", .{ options.input, source_extensions[source_extensions.len - 1], }); return @backingInt(Status.file_error); }, else => return err, };
if (!options.quiet) try stdout.print("{s}\n", .{banner.long});
const listing_path = options.listing_path orelse try std.fmt.allocPrint(arena, "{s}.lst", .{base}); const output_path = options.output_path orelse try std.fmt.allocPrint(arena, "{s}{s}", .{ base, switch (options.format) { .binary => ".bin", .s_records => ".s19", .intel_hex => ".hex", }, });
var listing_buffer: [8192]u8 = undefined; var listing_sink: ?Io.File.Writer = null; var listing_writer: ?*Io.Writer = null; if (!isNul(listing_path)) { if (std.mem.eql(u8, listing_path, "-")) { listing_writer = stdout; } else { const file = Io.Dir.cwd().createFile(io, listing_path, .{}) catch { try stdout.print("Can't create output file {s}.\n", .{listing_path}); return @backingInt(Status.file_error); }; listing_sink = .init(file, io, &listing_buffer); listing_writer = &listing_sink.?.interface; } } defer if (listing_sink) |*sink| { sink.interface.flush() catch {}; sink.file.close(io); };
const image = try gpa.create(Image); defer gpa.destroy(image); image.* = .{};
var assembler = Assembler.init(gpa, files.source(), &options, image, stdout); assembler.listing = listing_writer; assembler.listing_is_console = listing_writer == stdout; if (!options.quiet) assembler.progress = stderr; defer assembler.deinit();
if (options.verbose) { // The blank line separates the banner from what follows, so it goes // only when there was a banner. if (!options.quiet) try stdout.writeByte('\n'); try stdout.print("Optimization {s}abled.\n", .{if (options.optimize) "en" else "dis"}); switch (options.cpu) { .mos6502 => {}, .mos6502x => try stdout.writeAll("Undefined 6502 extensions enabled.\n"), .g65sc02 => try stdout.writeAll("65SC02 extensions enabled.\n"), } } assembler.assemble() catch |err| switch (err) { // The file was there a moment ago, when its name was resolved. error.InputNotFound => { try stdout.print("Can't open source file {s}.\n", .{options.input}); return @backingInt(Status.file_error); }, else => return err, }; // The debug information is written whether the assembly worked or not. // One that fell over in pass one has the labels that pass managed to // define and no line table at all; one that got to pass two has both, // errors or no errors. if (options.debug_info) { writeDebugInfo(gpa, io, stdout, &assembler, &options, output_path, assembler.reached_pass2) catch |err| switch (err) { error.OutputFileNotCreated => return @backingInt(Status.file_error), else => return err, }; }
if (assembler.failed()) { // No object file when the assembly failed, but the listing is still // closed off with its form feed. try assembler.finishListing(true); return @backingInt(Status.assembly_errors); }
writeOutput(io, stdout, &assembler, &options, image, output_path) catch |err| switch (err) { error.OutputFileNotCreated => return @backingInt(Status.file_error), else => return err, }; return @backingInt(Status.ok);}
fn writeOutput( io: Io, stdout: *Io.Writer, assembler: *Assembler, options: *const Options, image: *const Image, path: []const u8,) !void { const kind = switch (options.format) { .binary => "binary", .s_records => "s-records", .intel_hex => "intel-hex", }; // A file called `nul` is thrown away, but the listing is still rounded off // the way the chosen format rounds it off. const wanted = !isNul(path); if (wanted and options.verbose and options.format == .binary) { try stdout.writeAll("Checking binary file boundaries.\n"); } if (wanted and options.verbose) { try stdout.print("Writing {s} file \"{s}\".\n", .{ kind, path }); }
var buffer: [8192]u8 = undefined; var sink: ?Io.File.Writer = null; if (wanted) { const file = Io.Dir.cwd().createFile(io, path, .{}) catch { try stdout.print("Can't create output file {s}.\n", .{path}); return error.OutputFileNotCreated; }; sink = .init(file, io, &buffer); } defer if (sink) |*s| { s.interface.flush() catch {}; s.file.close(io); };
switch (options.format) { .binary => { if (sink) |*s| { try image.writeBinary(&s.interface, if (options.zero_fill) 0x00 else 0xff); var report: [256]u8 = undefined; var block: Io.Writer = .fixed(&report); if (!image.isEmpty()) { try block.print( "Wrote binary from address ${x:0>4} through ${x:0>4}.\nTotal size {d} bytes.\n", .{ image.lowest, image.highest, image.highest - image.lowest + 1 }, ); } if (assembler.start_address) |entry| { try block.print("Program start address is at ${x:0>4} ({d}).\n", .{ @as(u32, @bitCast(entry)) & 0xffff, entry, }); } if (block.buffered().len != 0) try assembler.report(block.buffered()); } // The form feed that closes the listing belongs to the binary // path; a run that writes s-records or intel-hex leaves it off. try assembler.finishListing(false); }, // Neither of these ends the listing with a form feed, but a page the // listing has already filled is still started. .s_records => { if (sink) |*s| { try image.writeSRecords(&s.interface, entryAddress(assembler), options.zero_fill); } try assembler.flushPendingPage(); }, .intel_hex => { if (sink) |*s| try image.writeIntelHex(&s.interface, entryAddress(assembler)); try assembler.flushPendingPage(); }, }}
/// Write the `-g` file, named after the object file with `.dbg` in place of/// its extension. It is written even when the object file itself was thrown/// away with `-onul`.fn writeDebugInfo( gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, assembler: *Assembler, options: *const Options, output_path: []const u8, complete: bool,) !void { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator();
const path = try std.fmt.allocPrint(arena, "{s}.dbg", .{stemOf(output_path)});
const info = try assembler.debugInfo(arena); var buffer: [8192]u8 = undefined; const file = Io.Dir.cwd().createFile(io, path, .{}) catch { try stdout.print("Can't create output file {s}.\n", .{path}); return error.OutputFileNotCreated; }; defer file.close(io); var sink: Io.File.Writer = .init(file, io, &buffer); try aigrette65.debug.write(&sink.interface, switch (options.cpu) { .mos6502 => .mos6502, .mos6502x => .mos6502x, .g65sc02 => .g65sc02, }, info.sources, info.symbols, info.lines, complete); try sink.interface.flush();}
/// Find the source file, trying the name as it was given and then that name/// with each of `source_extensions` pasted on the end. The name that opened is/// what the listing header and the diagnostics show.fn resolveSource(files: *const Files, arena: std.mem.Allocator, given: []const u8) ![]const u8 { if (files.exists(given)) return given; for (source_extensions) |extension| { const candidate = try std.fmt.allocPrint(arena, "{s}{s}", .{ given, extension }); if (files.exists(candidate)) return candidate; } return error.FileNotFound;}
/// The start address `end` named, as an address rather than a signed value.fn entryAddress(assembler: *const Assembler) ?u32 { const value = assembler.start_address orelse return null; return @as(u32, @bitCast(value)) & 0xffff;}
/// The input file name with its extension removed, which is what the default/// listing and output names are built from.fn stemOf(path: []const u8) []const u8 { const slash = std.mem.lastIndexOfAny(u8, path, "/\\"); const start = if (slash) |i| i + 1 else 0; const dot = std.mem.lastIndexOfScalar(u8, path[start..], '.') orelse return path; if (dot == 0) return path; return path[0 .. start + dot];}
/// `as65` discards any output file called `nul`.fn isNul(path: []const u8) bool { return std.ascii.eqlIgnoreCase(path, "nul");}
test "the stem drops one extension and nothing else" { try std.testing.expectEqualStrings("pg", stemOf("pg.a65")); try std.testing.expectEqualStrings("pg", stemOf("pg")); try std.testing.expectEqualStrings("dir.d/pg", stemOf("dir.d/pg.a65")); try std.testing.expectEqualStrings("dir.d/pg", stemOf("dir.d/pg"));}