diff --git a/docs/Zero-Assembly-Intro.md b/docs/Zero-Assembly-Intro.md index 95a8100..fb62b02 100644 --- a/docs/Zero-Assembly-Intro.md +++ b/docs/Zero-Assembly-Intro.md @@ -27,11 +27,16 @@ The Zero compiler project assembly language support. EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI -### Directives +### Data Directives -* `db [, ...]` (supports decimal or 0x..) -* `dw [, ...]` -* `dd [, ...]` +Data Directives are used to allocate data of varying widths. + +| Directive | Full Name | Size in Bytes | Use Case | Sample | +|-|-|-|-|-| +| `db` | Define Byte | 1 byte (8-bit) | Characters, strings, etc. | `db [, ...]` (supports decimal or 0x..) +| `dw` | Define Word | 2 | 16-bit integers, memory offsets, Unicode/UTF-16 characters | `dw [, ...]` | +| `dd` | Define Double Word | 4 | 32-bit integers, single-precision floats, memory addresses | `dd [, ...]` | +| `dq` | Define Quad Word | 8 | 64-bit pointers, 64-bit integers, double-precision floats | `` | ## Other diff --git a/docs/Future-Formats-and-Targets.md b/docs/designs/Future-Formats-and-Targets.md similarity index 100% rename from docs/Future-Formats-and-Targets.md rename to docs/designs/Future-Formats-and-Targets.md diff --git a/docs/designs/ZSRC-v0.3-POC-Console.md b/docs/designs/ZSRC-v0.3-POC-Console.md index fa37a2a..a051db7 100644 --- a/docs/designs/ZSRC-v0.3-POC-Console.md +++ b/docs/designs/ZSRC-v0.3-POC-Console.md @@ -2,16 +2,22 @@ In the next generation of Zero, we are re-aligning with the original design specifications and proof-of-concept. +## Zero Source (ZSRC) + +The proof-of-concept of the early passes of the Zero language. In its early form, it has a resemblance to C and C#, where it aims to not use pointers + +Provides the ability to define the platform and target architecture + ```csharp // Zero v0.2.0-pre // Demonstrates high-level syntax // Compiler options -option Platform = WinPE; // Specify the target platform -option Arch = x64; // "Architecture" or "Arch" will work +option Platform(WinPE); // Specify the target platform +option Target(x64); // Target or Architecture ("Arch") // Library header inclusion -using "Windows.zh"; +using "Windows.zh"; // '.zh' denotes, Zero Header. // Main entry point int main() @@ -29,3 +35,58 @@ int main() return 0; } +``` + +## Zero Assembly (ZASM) + +### Raw + +Ideal for copy/pasting in from other assemblers. + +```csharp +section .data + greeting db 'Hi!', 0x0A ; 'H', 'i', '!', and a newline character (4 bytes) + +section .text + global _start + +_start: + mov rax, 1 ; sys_write system call + mov rdi, 1 ; file descriptor 1 (stdout) + mov rsi, greeting ; pass the pointer to our DB string + mov rdx, 4 ; the explicit length of our string + syscall ; execute + + mov rax, 60 ; sys_exit system call + xor rdi, rdi ; return code 0 + syscall +``` + +### Zero's C-Like Assembly + +Zero's C-Like assemblly syntax is intended to catch line errors and accidental section misplacements by use of curley braces `{ }` and semicolons `;`. + +```csharp +section .data +{ + greeting db 'Hi!', 0x0A; // 'H', 'i', '!', and a newline character (4 bytes) +} + +section .text +{ + global _start +} + +_start: +{ + mov rax, 1; // sys_write system call + mov rdi, 1; // file descriptor 1 (stdout) + mov rsi, greeting; // pass the pointer to our DB string + mov rdx, 4; // the explicit length of our string + syscall; // execute + + mov rax, 60; // sys_exit system call + xor rdi, rdi; // return code 0 + syscall; +} +``` diff --git a/docs/readme.md b/docs/readme.md index f551dc3..e030c8f 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -20,7 +20,7 @@ When compiling a single `.zsrc` file, the code can use compiler directives in th * zsrc - source code * zasm - assembly source code -* zinc - Include / header file +* zinc - Include / header file (zinc, zh) * zprj - Multi-file zero project (_coming soon_) ## Sample Zero Assembly diff --git a/docs/tutorial-zasm-concat.md b/docs/tutorial-zasm-concat.md new file mode 100644 index 0000000..4df6ca5 --- /dev/null +++ b/docs/tutorial-zasm-concat.md @@ -0,0 +1,112 @@ +# Joining strings in Zero Assembly + +[hello-concat.zasm](../samples/hello-concat.zasm) prints two pieces of text, then +joins them with a space. Joining strings is called **concatenation**. + +## 1. Import helper functions + +```asm +#IMPORT msvcrt.dll, puts, strcpy, strcat, exit +``` + +This loads functions from a Windows library: + +| Function | Job | +| --- | --- | +| `puts` | Print text and move to the next line. | +| `strcpy` | Copy text into another memory area. | +| `strcat` | Add text to the end of an existing string. | +| `exit` | Finish the program. | + +## 2. Make room for the joined text + +```asm +sub esp, 64 +lea esi, [esp] +``` + +This reserves 64 bytes on the **stack**, a temporary storage area. `esi` is a +CPU register: a small storage location holding the buffer's address. + +The final string needs 21 bytes, including its ending marker. + +## 3. Store the words + +```asm +call hello_ready +hello_text: + db 72, 101, 108, 108, 111, 0 +hello_ready: + pop edi +``` + +`db` stores bytes. Here, the numbers are ASCII codes spelling `Hello`. +The final `0` marks the end; it is not the character `0`. + +`call` jumps over the text and saves its address on the stack. `pop edi` takes +that address into `edi`. The program repeats this trick for `Zero Assembly!`, +storing its address in `ebx`. + +## 4. Print each string + +```asm +push edi +call [IAT_puts] +add esp, 4 +``` + +`push` supplies the string's address to `puts`. `IAT_puts` points to the imported +function. Afterward, `add esp, 4` removes that argument from the stack. +The program repeats this for the second string. + +## 5. Build and print the combined string + +The next calls change the buffer in three steps: + +```text +strcpy → Hello +strcat → Hello[space] +strcat → Hello Zero Assembly! +``` + +For these two-argument functions, the source address is pushed first, then the +destination address. `add esp, 8` removes both arguments afterward. + +The program prints the buffer with `puts`, then calls `exit` with `0` to signal +success. The joined sentence is built while the program runs. + +## Try it + +From the repository root, after building the compiler: + +```powershell +./zero.exe run samples/hello-concat.zasm +``` + +Output: + +```text +Hello +Zero Assembly! +Hello Zero Assembly! +``` + +## Reference + +A **mnemonic** is a short name for a CPU instruction. This table covers the +instructions and assembler directives used in the sample. + +| Mnemonic | Name | What it does | Example from the sample | +| --- | --- | --- | --- | +| `sub` | Subtract | Subtracts a number. Here, moving the stack pointer down reserves 64 bytes of storage. | `sub esp, 64` | +| `lea` | Load effective address | Saves a memory address in a register. It does not read the text stored there. | `lea esi, [esp]` | +| `call` | Call a function or label | Saves the return address on the stack, then jumps to the requested code. | `call hello_ready` | +| `pop` | Take from the stack | Takes the top value off the stack and puts it in a register. | `pop edi` | +| `push` | Place on the stack | Places a value on the stack. This sample uses it to pass function arguments. | `push edi` | +| `add` | Add | Adds a number. Here, moving the stack pointer up removes a used argument. | `add esp, 4` | +| `db` | Define byte | Stores raw bytes in the program, such as ASCII letters and an ending zero. This is an assembler directive. | `db 32, 0` stores a space and its ending zero. | +| `#IMPORT` | Import functions | Declares which functions the program needs from a DLL. This is an assembler directive. | `#IMPORT msvcrt.dll, puts, strcpy, strcat, exit` | + +Names ending in `:`, such as `hello_ready:`, are **labels** that name locations +in the program. Names such as `esp`, `esi`, and `edi` are **registers**, not +instructions. `esp` holds the address of the top of the stack. diff --git a/readme.md b/readme.md index ab3c0a7..50d557c 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,7 @@ source/ Assembly/ Assembler, parsed lines, directives, and import requests Core/ Shared utility functions Diagnostics/ Error codes and assembler exceptions + Parsing/ Quote-aware operand and string parsing Formats/ Pe/ PE writer, layout, imports, and binary structures Targets/ diff --git a/samples/hello-concat.zasm b/samples/hello-concat.zasm new file mode 100644 index 0000000..e419d98 --- /dev/null +++ b/samples/hello-concat.zasm @@ -0,0 +1,73 @@ +; Windows x86 console application: print two strings, then concatenate them. +; From the published folder: +; .\zcc.exe run samples\hello-concat.zasm +; Expected output: +; Hello +; Zero Assembly! +; Hello Zero Assembly! + +#IMPORT msvcrt.dll, puts, strcpy, strcat, exit + +start: + ; Reserve a writable stack buffer. The joined text needs 21 bytes including + ; the terminating zero; 64 bytes is sufficient for these fixed strings. + sub esp, 64 + lea esi, [esp] ; ESI = destination buffer. + + ; CALL skips inline data and supplies its address as the return address. + call hello_ready + +hello_text: + ; Null-terminated ASCII: "Hello". + db 72, 101, 108, 108, 111, 0 + +hello_ready: + pop edi ; EDI = first string. + call second_ready + +second_text: + ; Null-terminated ASCII: "Zero Assembly!". + db 90, 101, 114, 111, 32, 65, 115, 115, 101, 109, 98, 108, 121, 33, 0 + +second_ready: + pop ebx ; EBX = second string. + + ; puts prints a string and adds a newline. + push edi + call [IAT_puts] + add esp, 4 ; The caller cleans up cdecl arguments. + + push ebx + call [IAT_puts] + add esp, 4 + + ; strcpy(buffer, first): initialize the buffer with "Hello" and its zero. + push edi + push esi + call [IAT_strcpy] + add esp, 8 + + call space_ready +space_text: + db 32, 0 ; Null-terminated ASCII space. + +space_ready: + pop eax + ; strcat(buffer, " "): replace the old zero with a space and a new zero. + push eax + push esi + call [IAT_strcat] + add esp, 8 + + ; strcat(buffer, second): build "Hello Zero Assembly!" at runtime. + push ebx + push esi + call [IAT_strcat] + add esp, 8 + + push esi + call [IAT_puts] + add esp, 4 + + push 0 + call [IAT_exit] ; Flush console output and terminate with code 0. diff --git a/samples/hello-db-vars.zasm b/samples/hello-db-vars.zasm new file mode 100644 index 0000000..4852d85 --- /dev/null +++ b/samples/hello-db-vars.zasm @@ -0,0 +1,22 @@ +; Windows x86 console application: print a named DB string. +; From the published folder: +; .\zcc.exe run samples\hello-db-vars.zasm +; Expected output: Hello, World! + +#IMPORT msvcrt.dll, printf, exit + + jmp start ; Skip inline data so the CPU starts with instructions. + +message db 'Hello, World!', 0x0A, 0 ; Text, newline (10), and ending zero. +format db '%s', 0 ; Print message as text. + +start: + ; The name message resolves to the address of the declared bytes. + ; printf(format, message) takes arguments pushed right to left. + push message + push format + call [IAT_printf] + add esp, 8 ; Remove both 32-bit arguments from the stack. + + push 0 + call [IAT_exit] ; Flush console output and exit successfully. diff --git a/samples/v002-hello-msgbox.zasm b/samples/hello-msgbox.zasm similarity index 100% rename from samples/v002-hello-msgbox.zasm rename to samples/hello-msgbox.zasm diff --git a/samples/readme.md b/samples/readme.md index dc36c2f..f150473 100644 --- a/samples/readme.md +++ b/samples/readme.md @@ -14,6 +14,8 @@ From the `publish` folder, compile and run a sample: | Sample | Behavior | | --- | --- | | `hello-console.zasm` | Prints `Hello from Zero!` using Windows console output. | +| `hello-concat.zasm` | Prints `Hello` and `Zero Assembly!` on separate lines, then joins them with a space and prints `Hello Zero Assembly!`. | +| `hello-db-vars.zasm` | Declares a named string with `message db 'Hello, World!', 0x0A, 0` and prints it. | | `v002-hello-msgbox.zasm` | Opens a Windows MessageBox titled `Zero` displaying `Hello Zero!`. Click OK to exit. | | `v002-integer-math.zasm` | Calculates `7 + 5` and `6 * 7`, then prints the computed results. | @@ -29,6 +31,17 @@ above create their executables inside `samples`. To choose another output path: Omit `run` to compile without executing. Compilation failures prevent execution, including when an executable from an earlier successful build already exists. +Run the string-concatenation sample with: + +```powershell +.\zcc.exe run samples\hello-concat.zasm +``` + +It uses `strcpy` and `strcat` from Windows' system `msvcrt.dll` to build the final +string in a writable 64-byte stack buffer. `puts` prints each line, and `exit` +flushes output. The joined string is constructed at runtime from the two source +strings and a space; its fixed contents require 21 bytes including the final zero. + The arithmetic sample produces: ```text @@ -47,3 +60,28 @@ These applications target Windows x86. The compiler currently writes the console subsystem for all executables, so the MessageBox sample can also open a console window when launched from Explorer. Run console samples from an existing terminal to keep their output visible. + +## Named byte declarations + +```asm +message db 'Hello, World!', 0x0A, 0 +``` + +`message` names the address of these bytes. `push message` passes that address; +`mov eax, message` or `lea eax, [message]` can also load it. A colon is optional +for byte declarations: `message: db 'Hello, World!', 0x0A, 0` works too. + +Quoted strings emit UTF-8 bytes and can use single or double quotes. Commas, +semicolons, and colons inside a string are ordinary text. Double a matching quote +to include it, as in `'It''s Zero'`. Backslashes are literal; use numeric bytes +such as `0x0A` for a newline and `0` for a terminating zero. No ending zero is +added automatically. + +Data is currently stored inline with the code in the read-only `.text` section. +When declarations come before instructions, use `jmp start` to skip the data, as +shown in `hello-db-vars.zasm`. These names identify stored bytes; they do not yet +provide writable variables or separate data sections. + +```powershell +.\zcc.exe run samples\hello-db-vars.zasm +``` diff --git a/source/Zero.Tests/ZAsmTests/AssemblerTests.cs b/source/Zero.Tests/ZAsmTests/AssemblerTests.cs index 9a998aa..212e823 100644 --- a/source/Zero.Tests/ZAsmTests/AssemblerTests.cs +++ b/source/Zero.Tests/ZAsmTests/AssemblerTests.cs @@ -12,6 +12,8 @@ public class AssemblerTests { [TestMethod] [DataRow("hello-console.zasm", "Hello from Zero!\r\n")] + [DataRow("hello-db-vars.zasm", "Hello, World!\r\n")] + [DataRow("hello-concat.zasm", "Hello\r\nZero Assembly!\r\nHello Zero Assembly!\r\n")] [DataRow("v002-integer-math.zasm", "7 + 5 = 12\r\n6 * 7 = 42\r\n")] public void ConsoleSamplePrintsExpectedOutput(string sampleName, string expectedOutput) { diff --git a/source/Zero.Tests/ZAsmTests/DataDirectiveTests.cs b/source/Zero.Tests/ZAsmTests/DataDirectiveTests.cs new file mode 100644 index 0000000..d30953d --- /dev/null +++ b/source/Zero.Tests/ZAsmTests/DataDirectiveTests.cs @@ -0,0 +1,161 @@ +// Copyright Xeno Innovations, Inc. 2026 +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection.PortableExecutable; +using System.Text; +using Zero.Assembly; +using Zero.Exceptions; + +namespace Zero.Tests.ZAsmTests; + +[TestClass] +public sealed class DataDirectiveTests +{ + [TestMethod] + [DataRow("message db 'Hello, World!', 0x0A, 0 ; newline", "Hello, World!\n\0")] + [DataRow("message\tDB\t\"Zero; Assembly: Hello, World!\", 0", "Zero; Assembly: Hello, World!\0")] + [DataRow("message: db 'It''s Zero', 0", "It's Zero\0")] + [DataRow("db \"Say \"\"Hi\"\"\", 0", "Say \"Hi\"\0")] + [DataRow("db '', 1, \"\", 2", "\u0001\u0002")] + [DataRow("db 'c:\\temp', 0", "c:\\temp\0")] + [DataRow("db 'caf\u00e9', 0", "caf\u00e9\0")] + [DataRow("db 'Hi', 0 ; ignore an unmatched quote here '", "Hi\0")] + public void QuotedAndNumericOperandsEmitExpectedBytes(string source, string expected) + { + var (code, _) = ReadTextSection(Assemble(source)); + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes(expected), code); + } + + [TestMethod] + public void DataAddressesAndRelativeJumpsUseTheirCorrectAddressSpaces() + { + var (code, address) = ReadTextSection(Assemble(""" + jmp start + message db 'abc', 0 + start: + push message + mov eax, message + lea ecx, [message] + ret + """)); + + Assert.AreEqual(26, code.Length); + Assert.AreEqual(4, BitConverter.ToInt32(code, 1)); // JMP skips the four data bytes. + Assert.AreEqual((byte)0x68, code[9]); // PUSH imm32. + Assert.AreEqual(address + 5, BitConverter.ToUInt32(code, 10)); + Assert.AreEqual((byte)0xB8, code[14]); // MOV EAX, imm32. + Assert.AreEqual(address + 5, BitConverter.ToUInt32(code, 15)); + Assert.AreEqual((byte)0x8D, code[19]); // LEA ECX, [disp32]. + Assert.AreEqual(address + 5, BitConverter.ToUInt32(code, 21)); + Assert.AreEqual((byte)0xC3, code[25]); // No padding trap after LEA. + } + + [TestMethod] + public void ForwardDataReferenceEndingInHResolvesBeforeHexNumbers() + { + var (code, address) = ReadTextSection(Assemble(""" + push path + ret + path db 'C:', 0 + """)); + + Assert.AreEqual(address + 6, BitConverter.ToUInt32(code, 1)); + CollectionAssert.AreEqual(new byte[] { 67, 58, 0 }, code[6..]); + } + + [TestMethod] + [DataRow("message db 'unterminated")] + [DataRow("message db 'abc' garbage")] + [DataRow("message db 1,,2")] + [DataRow("message db 1,")] + [DataRow("message db")] + [DataRow("message db 1\nmessage db 2")] + [DataRow("message: db 1\nmessage db 2")] + [DataRow("eax db 1")] + [DataRow("1message db 1")] + public void MalformedDeclarationsReportTheirSourceLine(string source) + { + var error = Assert.ThrowsExactly(() => Assemble(source)); + StringAssert.Contains(error.Message, "Line "); + } + + [TestMethod] + [DataRow("push message")] + [DataRow("mov eax, message\n push eax")] + [DataRow("lea eax, [message]\n push eax")] + public void NamedDataCanBePassedToAnImportedFunction(string addressInstructions) + { + if (!OperatingSystem.IsWindows()) + Assert.Inconclusive("The assembler produces Windows x86 executables."); + + var bytes = Assemble($""" + #IMPORT msvcrt.dll, printf, exit + jmp start + message db 'Hello, World!', 0x0A, 0 + start: + {addressInstructions} + call [IAT_printf] + add esp, 4 + push 0 + call [IAT_exit] + """); + var executable = Path.Combine(Path.GetTempPath(), $"zero-db-{Guid.NewGuid():N}.exe"); + + try + { + File.WriteAllBytes(executable, bytes); + using var process = Process.Start(new ProcessStartInfo(executable) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + }); + + Assert.IsNotNull(process); + + if (!process.WaitForExit(10000)) + { + process.Kill(); + process.WaitForExit(); + Assert.Fail("The named-data sample timed out."); + } + + Assert.AreEqual(0, process.ExitCode); + Assert.AreEqual("Hello, World!\r\n", process.StandardOutput.ReadToEnd()); + } + finally + { + File.Delete(executable); + } + } + + private static byte[] Assemble(string source) + { + var bytes = new Assembler().Assemble(source.Split('\n')); + Assert.IsNotNull(bytes); + return bytes; + } + + private static (byte[] Code, uint Address) ReadTextSection(byte[] executable) + { + using var stream = new MemoryStream(executable); + using var reader = new PEReader(stream); + + foreach (var section in reader.PEHeaders.SectionHeaders) + { + if (section.Name != ".text") + continue; + + var content = reader.GetSectionData(section.VirtualAddress).GetContent(0, section.VirtualSize); + var code = new byte[content.Length]; + content.CopyTo(code); + + return (code, checked((uint)(reader.PEHeaders.PEHeader!.ImageBase + (uint)section.VirtualAddress))); + } + + throw new InvalidOperationException("Missing .text section."); + } +} diff --git a/source/Zero/Assembly/Assembler.cs b/source/Zero/Assembly/Assembler.cs index 85721f4..e7a2519 100644 --- a/source/Zero/Assembly/Assembler.cs +++ b/source/Zero/Assembly/Assembler.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Text; using Zero.Core; using Zero.Exceptions; +using Zero.Parsing; namespace Zero.Assembly; @@ -13,58 +15,54 @@ namespace Zero.Assembly; /// r: register, m: memory address, imm: immediate (i.e. a value). public class Assembler { - private const char BracketEnd = ']'; - private const char BracketStart = '['; - private const char ColonChar = ':'; - private const char CommaChar = ','; - private const char CommentLine1 = ';'; - private const string CommentLine2 = "//"; - private const string DotDll = ".dll"; - private const string HexPrefix = "0x"; - private const string HexSuffix = "h"; - private const char PeriodChar = '.'; - private const char SpaceChar = ' '; - private const char TabChar = '\t'; - /// 32-bit registers. private static readonly Dictionary Registers = new(StringComparer.OrdinalIgnoreCase) { + // 64-bit registers + [Register.RAX] = 0, + [Register.RCX] = 1, + [Register.RDX] = 2, + [Register.RBX] = 3, + [Register.RSP] = 4, + [Register.RBP] = 5, + [Register.RSI] = 6, + [Register.RDI] = 7, // 32-bit registers - ["EAX"] = 0, // Accumulator register - ["ECX"] = 1, // Count register - ["EDX"] = 2, // Data register - ["EBX"] = 3, // Base register - ["ESP"] = 4, // Stack pointer - ["EBP"] = 5, // Base pointer - ["ESI"] = 6, // Source index - ["EDI"] = 7, // Destination index + [Register.EAX] = 0, // Accumulator register + [Register.ECX] = 1, // Count register + [Register.EDX] = 2, // Data register + [Register.EBX] = 3, // Base register + [Register.ESP] = 4, // Stack pointer + [Register.EBP] = 5, // Base pointer + [Register.ESI] = 6, // Source index + [Register.EDI] = 7, // Destination index // 16-bit registers - ["AX"] = 0, - ["CX"] = 1, - ["DX"] = 2, - ["BX"] = 3, - ["SP"] = 4, - ["BP"] = 5, - ["SI"] = 6, - ["DI"] = 7, + [Register.AX] = 0, + [Register.CX] = 1, + [Register.DX] = 2, + [Register.BX] = 3, + [Register.SP] = 4, + [Register.BP] = 5, + [Register.SI] = 6, + [Register.DI] = 7, // 8-bit registers - ["AL"] = 0, - ["CL"] = 1, - ["DL"] = 2, - ["BL"] = 3, - ["AH"] = 4, - ["CH"] = 5, - ["DH"] = 6, - ["BH"] = 7, + [Register.AL] = 0, + [Register.CL] = 1, + [Register.DL] = 2, + [Register.BL] = 3, + [Register.AH] = 4, + [Register.CH] = 5, + [Register.DH] = 6, + [Register.BH] = 7, // 128-bit registers - ["XMM0"] = 0, - ["XMM1"] = 1, - ["XMM2"] = 2, - ["XMM3"] = 3, - ["XMM4"] = 4, - ["XMM5"] = 5, - ["XMM6"] = 6, - ["XMM7"] = 7, + [Register.XMM0] = 0, + [Register.XMM1] = 1, + [Register.XMM2] = 2, + [Register.XMM3] = 3, + [Register.XMM4] = 4, + [Register.XMM5] = 5, + [Register.XMM6] = 6, + [Register.XMM7] = 7, }; private readonly List _imports = []; @@ -91,8 +89,16 @@ public class Assembler var pe = new PeWriter(); var plan = pe.PlanLayout(codeSize, Imports); - // Pass 2: Emit code, now that extern symbols (IAT_*) have absolute addresses - var codeBytes = EmitBytes(plan.ExternSymbolMap); + // Address operands need virtual addresses; jumps still use section offsets. + var symbols = new Dictionary(plan.ExternSymbolMap, StringComparer.OrdinalIgnoreCase); + foreach (var (label, offset) in _labels) + { + if (!symbols.TryAdd(label, plan.ImageBase + plan.TextRva + (uint)offset)) + throw new AssemblerException($"Label '{label}' conflicts with an imported symbol"); + } + + // Pass 2: Emit code with resolved addresses for local and imported symbols. + var codeBytes = EmitBytes(symbols); ////if (HasObjectFile) ////{ @@ -108,6 +114,23 @@ public class Assembler private static int CountDataBytes(List ops, int unitSize) => ops.Count * unitSize; + private static byte[] GetDefinedBytes(AsmLine line) + { + if (line.Operands.Count == 0) + throw new AssemblerException($"Line {line.LineNumber}: DB expects at least one operand"); + + var bytes = new List(); + foreach (var operand in line.Operands) + { + if (operand[0] is Constants.SingleQuote or Constants.DoubleQuote) + bytes.AddRange(Encoding.UTF8.GetBytes(AssemblySyntax.ReadString(operand, line.LineNumber))); + else + bytes.Add((byte)(ParseInt(operand, line.LineNumber) & 0xFF)); + } + + return bytes.ToArray(); + } + /// Basic size estimation for two-pass assembly. /// Assembly line. /// Size. @@ -122,7 +145,7 @@ private static int EstimateSize(AsmLine ln) case Mnemonic.INT: return 2; - case Mnemonic.DB: return CountDataBytes(ln.Operands, 1); + case Mnemonic.DB: return GetDefinedBytes(ln).Length; case Mnemonic.DW: return CountDataBytes(ln.Operands, 2); case Mnemonic.DD: return CountDataBytes(ln.Operands, 4); @@ -154,14 +177,24 @@ private static int EstimateSize(AsmLine ln) return (ln.Operands.Count == 2 && IsRegister(ln.Operands[1])) ? 2 : 6; case Mnemonic.LEA: - // 8D /r + [ModRM] + optional SIB + disp32 - // Estimate worst-case with disp32 + SIB: 7 bytes - return 7; + { + if (ln.Operands.Count != 2 || !IsMemory(ln.Operands[1])) + throw new AssemblerException($"Line {ln.LineNumber}: LEA expects a register and [mem]"); + + // Only ESP addressing needs a SIB byte. Exact sizes keep labels aligned + // and avoid inserting an executable 0xCC byte after LEA [symbol]. + var inner = ln.Operands[1].Trim()[1..^1].Trim(); + int plus = inner.IndexOf(Constants.PlusChar); + var memoryBase = (plus < 0 ? inner : inner[..plus]).Trim(); + + return memoryBase.Equals(Register.ESP, StringComparison.OrdinalIgnoreCase) ? 7 : 6; + } case Mnemonic.CALL: // CALL label => 5; CALL [abs32] => FF 15 + disp32 => 6 if (ln.Operands.Count != 1) throw new AssemblerException($"Line {ln.LineNumber}: CALL expects 1 operand"); + return IsMemoryAbsolute(ln.Operands[0]) ? 6 : 5; // Jump to label (label:) @@ -186,15 +219,15 @@ private static int EstimateSize(AsmLine ln) /// Input token. /// True if it is memory pointer. private static bool IsMemory(string token) => - token.Trim().StartsWith(BracketStart) && token.Trim().EndsWith(BracketEnd); + token.Trim().StartsWith(Constants.BracketStart) && token.Trim().EndsWith(Constants.BracketEnd); private static bool IsMemoryAbsolute(string token) { token = token.Trim(); // Absolute [imm32] or [symbol] - return token.StartsWith(BracketStart) && token.EndsWith(BracketEnd) && - !token.Contains('+') && !token.Contains("-"); + return token.StartsWith(Constants.BracketStart) && token.EndsWith(Constants.BracketEnd) && + !token.Contains(Constants.PlusChar) && !token.Contains(Constants.MinusChar); } /// Is token a register. @@ -212,12 +245,12 @@ private static bool IsValidLabel(string line) return false; // Must start with a letter, not underscore or period (section) - if (!(char.IsLetter(line[0]) || line[0] == '_' || line[0] == '.')) + if (!(char.IsLetter(line[0]) || line[0] == Constants.UnderscoreChar || line[0] == Constants.PeriodChar)) return false; // Must contain alpha-numeric only foreach (var c in line) - if (!(char.IsLetterOrDigit(c) || c == '_' || c == '.')) + if (!(char.IsLetterOrDigit(c) || c == Constants.UnderscoreChar || c == Constants.PeriodChar)) return false; return true; @@ -234,10 +267,10 @@ private static int ParseInt(string s, int lineNumber) s = s.Trim(); try { - if (s.StartsWith(HexPrefix, StringComparison.OrdinalIgnoreCase)) + if (s.StartsWith(Constants.HexPrefix, StringComparison.OrdinalIgnoreCase)) return Convert.ToInt32(s[2..], 16); - if (s.EndsWith(HexSuffix, StringComparison.OrdinalIgnoreCase)) + if (s.EndsWith(Constants.HexSuffix, StringComparison.OrdinalIgnoreCase)) return Convert.ToInt32(s[..^1], 16); return int.Parse(s, NumberStyles.Integer, CultureInfo.InvariantCulture); @@ -255,7 +288,7 @@ private static int ParseInt(string s, int lineNumber) private static AsmLine ParseLine(string line, int lineNum) { // Split mnemonic and operands (space or tab characters) - var firstSpace = line.IndexOfAny([SpaceChar, TabChar]); + var firstSpace = line.IndexOfAny([Constants.SpaceChar, Constants.TabChar]); string mnemonic, rest; if (firstSpace < 0) @@ -271,7 +304,7 @@ private static AsmLine ParseLine(string line, int lineNum) } mnemonic = mnemonic.Trim().ToUpperInvariant(); - var ops = SplitOperands(rest); + var ops = AssemblySyntax.SplitOperands(rest, lineNum); return new AsmLine { @@ -286,38 +319,27 @@ private static AsmLine ParseLine(string line, int lineNum) /// Index value. /// Base value. /// - private static byte SIB(byte scale, byte index, byte bBase) => + private static byte ScaleIndexBase(byte scale, byte index, byte bBase) => (byte)((scale << 6) | ((index & 7) << 3) | (bBase & 7)); - /// Parse line's operands after the mnemonic. - /// Source code. - /// Parsed data. - private static List SplitOperands(string s) - { - var list = new List(); - if (string.IsNullOrWhiteSpace(s)) - return list; - - var parts = s.Split(CommaChar); - foreach (var p in parts) - { - var t = p.Trim(); - if (!string.IsNullOrEmpty(t)) - list.Add(t); - } - - return list; - } - /// Strip comment from source code line. /// Source code line. /// Line without code comment. - private static string StripComment(string line) + private static string StripComment(string line, int lineNumber) { - var idx = line.IndexOf(CommentLine1); + var idx = AssemblySyntax.FindUnquoted(line, Constants.CommentLine1, lineNumber); return idx >= 0 ? line[..idx] : line; } + private void RegisterLabel(string label, int lineNumber) + { + if (!IsValidLabel(label) || IsRegister(label)) + throw new AssemblerException($"Line {lineNumber}: Invalid label '{label}'"); + + if (!_labels.TryAdd(label, _offset)) + throw new AssemblerException($"Line {lineNumber}: Duplicate label '{label}'"); + } + /// Pass 1) Parse and Size. Parse, Collect labels, imports and compute size. /// Source code lines. /// Size computed. @@ -333,24 +355,18 @@ private int AsmParseAndSize(string[] source) for (int i = 0; i < source.Length; i++) { var raw = source[i]; - var line = StripComment(raw); + var line = StripComment(raw, i + 1); if (string.IsNullOrWhiteSpace(line)) continue; // Labels: can be on their own line or prefixed before instruction - int colon = line.IndexOf(ColonChar); + int colon = AssemblySyntax.FindUnquoted(line, Constants.ColonChar, i + 1); if (colon >= 0) { var label = line[..colon].Trim(); - if (!IsValidLabel(label)) - throw new AssemblerException($"Invalid label '{label}' at line {i + 1}"); - - if (_labels.ContainsKey(label)) - throw new AssemblerException($"Duplicate label '{label}' at line {i + 1}"); - - _labels[label] = _offset; + RegisterLabel(label, i + 1); line = line[(colon + 1)..].Trim(); if (string.IsNullOrEmpty(line)) @@ -359,6 +375,22 @@ private int AsmParseAndSize(string[] source) // Clean up line for ez-parsing line = line.Trim(); + + // A byte declaration can name its data without a colon: message db 'Hi', 0. + int firstSpace = line.IndexOfAny([Constants.SpaceChar, Constants.TabChar]); + if (firstSpace >= 0) + { + var declaration = line[(firstSpace + 1)..].TrimStart(); + int directiveEnd = declaration.IndexOfAny([Constants.SpaceChar, Constants.TabChar]); + var directive = directiveEnd < 0 ? declaration : declaration[..directiveEnd]; + + if (directive.Equals(Mnemonic.DB, StringComparison.OrdinalIgnoreCase)) + { + RegisterLabel(line[..firstSpace], i + 1); + line = declaration; + } + } + var parsed = ParseLine(line, i + 1); // .idata importing externals @@ -430,12 +462,7 @@ private void EmitLine(List bytes, AsmLine ln, IReadOnlyDictionary bytes, AsmLine ln, IReadOnlyDictionary mod=00, rm=101 + disp32 @@ -619,10 +647,11 @@ private void EmitLine(List bytes, AsmLine ln, IReadOnlyDictionary bytes, AsmLine ln, IReadOnlyDictionary bytes, AsmLine ln, IReadOnlyDictionary 127) throw new AssemblerException(ErrorCode.SyntaxError, $"Line {ln.LineNumber}: short jump out of range ({rel8})"); - bytes.Add(ln.Mnemonic == "JZ8" ? (byte)0x74 : (byte)0x75); + bytes.Add(ln.Mnemonic == Mnemonic.JZ8 ? (byte)0x74 : (byte)0x75); ////bytes.Add((sbyte)rel8); bytes.Add((byte)rel8); break; @@ -729,13 +758,14 @@ private void ParseMemory(string s, AsmLine ln, IReadOnlyDictionary throw new AssemblerException($"Line {ln.LineNumber}: Empty memory operand"); // reg [+ imm] ? - var plusIdx = inner.IndexOf('+'); + var plusIdx = inner.IndexOf(Constants.PlusChar); if (plusIdx >= 0) { var left = inner[..plusIdx].Trim(); var right = inner[(plusIdx + 1)..].Trim(); if (!IsRegister(left)) throw new AssemblerException($"Line {ln.LineNumber}: Unsupported memory base '{left}'"); + hasBase = true; baseReg = Registers[left.ToUpperInvariant()]; disp32 = ResolveValue(right, ln, externs); @@ -771,8 +801,8 @@ private void RegisterImport(AsmLine line) throw new AssemblerException(ErrorCode.SyntaxErrorImport, $"Line {line.LineNumber}: IMPORT expects 'dll, func[, ...]'"); var dll = line.Operands[0].Trim(); - if (!dll.Contains(PeriodChar)) - dll += DotDll; + if (!dll.Contains(Constants.PeriodChar)) + dll += Constants.DotDll; ////var req = _imports.FirstOrDefault(x => x.Dll.Equals(dll, StringComparison.OrdinalIgnoreCase)); ImportRequest? req = null; @@ -817,7 +847,7 @@ private int ResolveMemoryAbsolute(string mem, AsmLine ln, IReadOnlyDictionaryGet numeric value, label offset, or extern symbol offset. + /// Get a numeric value or the virtual address of a local/imported symbol. /// Input operand. /// . /// Known externs. @@ -827,20 +857,16 @@ private int ResolveValue(string token, AsmLine ln, IReadOnlyDictionaryDisable CodeMaid auto-sorting +public class Register +{ + // -=[ 64-bit Registers ]=- + + /// 64-bit Accumulator Register. + public const string RAX = "RAX"; + /// 64-bit Counter Register. + public const string RCX = "RCX"; + /// 64-bit Data Register. + public const string RDX = "RDX"; + /// 64-bit Base Register. + public const string RBX = "RBX"; + /// 64-bit Stack Pointer Register. + public const string RSP = "RSP"; + /// 64-bit Base Pointer Register. + public const string RBP = "RBP"; + /// 64-bit Source Index Register. + public const string RSI = "RSI"; + /// 64-bit Destination Index Register. + public const string RDI = "RDI"; + + // -=[ 32-bit Registers ]=- + + /// 32-bit Accumulator Register. + public const string EAX = "EAX"; + /// 32-bit Base Register. + public const string EBX = "EBX"; + /// 32-bit Counter Register. + public const string ECX = "ECX"; + /// 32-bit Destination Index Register. + public const string EDI = "EDI"; + /// 32-bit Data Register. + public const string EDX = "EDX"; + + /// 32-bit Source Index Register. + public const string ESI = "ESI"; + + /// 32-bit Stack Pointer Register. + public const string ESP = "ESP"; + + /// 32-bit Base Pointer Register. + public const string EBP = "EBP"; + + // -=[ 16-bit Registers ]=- + + /// 16-bit Accumulator Register. + public const string AX = "AX"; + /// 16-bit Counter Register. + public const string CX = "CX"; + /// 16-bit Data Register. + public const string DX = "DX"; + /// 16-bit Base Register. + public const string BX = "BX"; + /// 16-bit Stack Pointer Register. + public const string SP = "SP"; + /// 16-bit Base Pointer Register. + public const string BP = "BP"; + /// 16-bit Source Index Register. + public const string SI = "SI"; + /// 16-bit Destination Index Register. + public const string DI = "DI"; + + // -=[ 8-bit Registers ]=- + /// 8-bit Accumulator Register. + public const string AL = "AL"; + /// 8-bit Counter Register. + public const string CL = "CL"; + /// 8-bit Data Register. + public const string DL = "DL"; + /// 8-bit Base Register. + public const string BL = "BL"; + /// 8-bit High Accumulator Register. + public const string AH = "AH"; + /// 8-bit High Counter Register. + public const string CH = "CH"; + /// 8-bit High Data Register. + public const string DH = "DH"; + /// 8-bit High Base Register. + public const string BH = "BH"; + + // -[ 128-bit Registers ]=- + + /* + * XMM0-XMM15: These registers are used for SIMD (Single Instruction, Multiple Data) operations, allowing for parallel processing of multiple data points in a single instruction. They are commonly used in applications that require high-performance computing, such as graphics processing, scientific simulations, and multimedia applications. + */ + + public const string XMM0 = "XMM0"; + public const string XMM1 = "XMM1"; + public const string XMM2 = "XMM2"; + public const string XMM3 = "XMM3"; + public const string XMM4 = "XMM4"; + public const string XMM5 = "XMM5"; + public const string XMM6 = "XMM6"; + public const string XMM7 = "XMM7"; +} diff --git a/source/Zero/Constants.cs b/source/Zero/Constants.cs new file mode 100644 index 0000000..30bb179 --- /dev/null +++ b/source/Zero/Constants.cs @@ -0,0 +1,28 @@ +// Copyright Xeno Innovations, Inc. 2026 +// See the LICENSE file in the project root for more information. + +namespace Zero; + +/// Shared assembly syntax tokens and naming conventions. +internal static class Constants +{ + internal const char BracketEnd = ']'; + internal const char BracketStart = '['; + internal const char ColonChar = ':'; + internal const char CommaChar = ','; + internal const char CommentLine1 = ';'; + internal const string CommentLine2 = "//"; + internal const char DoubleQuote = '"'; + internal const char MinusChar = '-'; + internal const char NullChar = '\0'; + internal const char PeriodChar = '.'; + internal const char PlusChar = '+'; + internal const char SingleQuote = '\''; + internal const char SpaceChar = ' '; + internal const char TabChar = '\t'; + internal const char UnderscoreChar = '_'; + + internal const string DotDll = ".dll"; + internal const string HexPrefix = "0x"; + internal const string HexSuffix = "h"; +} diff --git a/source/Zero/Parsing/AssemblySyntax.cs b/source/Zero/Parsing/AssemblySyntax.cs new file mode 100644 index 0000000..3bcba79 --- /dev/null +++ b/source/Zero/Parsing/AssemblySyntax.cs @@ -0,0 +1,86 @@ +// Copyright Xeno Innovations, Inc. 2026 +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text; +using Zero.Exceptions; + +namespace Zero.Parsing; + +internal static class AssemblySyntax +{ + // Delimiters inside quoted strings are data. A doubled quote escapes itself. + internal static int FindUnquoted(string text, char delimiter, int lineNumber, int start = 0) + { + char quote = Constants.NullChar; + for (int i = start; i < text.Length; i++) + { + var current = text[i]; + if (quote != Constants.NullChar) + { + if (current == quote) + { + if (i + 1 < text.Length && text[i + 1] == quote) + i++; + else + quote = Constants.NullChar; + } + } + else if (current is Constants.SingleQuote or Constants.DoubleQuote) + quote = current; + else if (current == delimiter) + return i; + } + + if (quote != Constants.NullChar) + throw new AssemblerException($"Line {lineNumber}: Unterminated quoted string"); + return -1; + } + + internal static List SplitOperands(string text, int lineNumber) + { + var operands = new List(); + if (string.IsNullOrWhiteSpace(text)) + return operands; + + int start = 0; + while (true) + { + int comma = FindUnquoted(text, Constants.CommaChar, lineNumber, start); + var operand = (comma < 0 ? text[start..] : text[start..comma]).Trim(); + + if (operand.Length == 0) + throw new AssemblerException($"Line {lineNumber}: Empty operand"); + + operands.Add(operand); + + if (comma < 0) + return operands; + start = comma + 1; + } + } + + internal static string ReadString(string operand, int lineNumber) + { + char quote = operand[0]; + var result = new StringBuilder(); + for (int i = 1; i < operand.Length; i++) + { + char current = operand[i]; + if (current != quote) + result.Append(current); + + else if (i + 1 < operand.Length && operand[i + 1] == quote) + { + result.Append(quote); + i++; + } + else if (i == operand.Length - 1) + return result.ToString(); + else + throw new AssemblerException($"Line {lineNumber}: Unexpected text after quoted string"); + } + + throw new AssemblerException($"Line {lineNumber}: Unterminated quoted string"); + } +}